diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 539f837c..1aef9a93 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -80,7 +80,6 @@ jobs: BUILDS_URL: ${{ secrets.BUILDS_URL }} BUILD_WEBHOOK_URL: ${{ secrets.BUILD_WEBHOOK_URL }} GITHUB_ACTOR: ${{ github.actor }} - run: node scripts/upload-build.cjs - name: Create artifact diff --git a/binaries/7z.dll b/binaries/7z.dll new file mode 100644 index 00000000..8ab081f5 Binary files /dev/null and b/binaries/7z.dll differ diff --git a/binaries/7z.exe b/binaries/7z.exe new file mode 100644 index 00000000..4774e2ee Binary files /dev/null and b/binaries/7z.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..dd10e81a 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -20,6 +20,9 @@ asarUnpack: - resources/** win: executableName: Hydra + extraResources: + - from: binaries/7z.exe + - from: binaries/7z.dll target: - nsis - portable @@ -35,6 +38,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 +49,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..803ca0ad 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "hydralauncher", - "version": "3.3.1", + "version": "3.4.0", "description": "Hydra", "main": "./out/main/index.js", "author": "Los Broxas", @@ -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..d6a1f687 100644 --- a/src/locales/en/translation.json +++ b/src/locales/en/translation.json @@ -44,7 +44,10 @@ "downloading_metadata": "Downloading {{title}} metadata…", "downloading": "Downloading {{title}}… ({{percentage}} complete) - Completion {{eta}} - {{speed}}", "calculating_eta": "Downloading {{title}}… ({{percentage}} complete) - Calculating remaining time…", - "checking_files": "Checking {{title}} files… ({{percentage}} complete)" + "checking_files": "Checking {{title}} files… ({{percentage}} complete)", + "installing_common_redist": "{{log}}…", + "installation_complete": "Installation complete", + "installation_complete_message": "Common redistributables installed successfully" }, "catalogue": { "search": "Filter…", @@ -193,7 +196,8 @@ "download_error_not_cached_in_real_debrid": "This download is not available on Real-Debrid and polling download status from Real-Debrid is not yet available.", "download_error_not_cached_in_torbox": "This download is not available on Torbox and polling download status from Torbox is not yet available.", "game_removed_from_favorites": "Game removed from favorites", - "game_added_to_favorites": "Game added to favorites" + "game_added_to_favorites": "Game added to favorites", + "automatically_extract_downloaded_files": "Automatically extract downloaded files" }, "activation": { "title": "Activate Hydra", @@ -230,7 +234,9 @@ "seeding": "Seeding", "stop_seeding": "Stop seeding", "resume_seeding": "Resume seeding", - "options": "Manage" + "options": "Manage", + "extract": "Extract files", + "extracting": "Extracting files…" }, "settings": { "downloads_path": "Downloads path", @@ -344,7 +350,11 @@ "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", + "common_redist": "Common redistributables", + "common_redist_description": "Common redistributables are required to run some games. Installing them is recommended to avoid issues.", + "install_common_redist": "Install", + "installing_common_redist": "Installing…" }, "notifications": { "download_complete": "Download complete", @@ -357,7 +367,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..5f2b62cc 100644 --- a/src/locales/pt-BR/translation.json +++ b/src/locales/pt-BR/translation.json @@ -44,7 +44,10 @@ "downloading_metadata": "Baixando metadados de {{title}}…", "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}}…" + "checking_files": "Verificando arquivos de {{title}}…", + "installing_common_redist": "{{log}}…", + "installation_complete": "Instalação concluída", + "installation_complete_message": "Componentes recomendados instalados com sucesso" }, "game_details": { "open_download_options": "Ver opções de download", @@ -182,7 +185,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 +223,9 @@ "seeding": "Semeando", "stop_seeding": "Parar de semear", "resume_seeding": "Semear", - "options": "Gerenciar" + "options": "Gerenciar", + "extract": "Extrair arquivos", + "extracting": "Extraindo arquivos…" }, "settings": { "downloads_path": "Diretório dos downloads", @@ -331,7 +337,11 @@ "error_importing_theme": "Erro ao importar tema", "theme_imported": "Tema importado com sucesso", "enable_friend_request_notifications": "Quando um pedido de amizade é recebido", - "enable_auto_install": "Baixar atualizações automaticamente" + "enable_auto_install": "Baixar atualizações automaticamente", + "common_redist": "Componentes recomendados", + "common_redist_description": "Componentes recomendados são necessários para executar alguns jogos. A instalação deles é recomendada para evitar problemas.", + "install_common_redist": "Instalar", + "installing_common_redist": "Instalando…" }, "notifications": { "download_complete": "Download concluído", @@ -342,7 +352,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/constants.ts b/src/main/constants.ts index 5e0b0409..48390250 100644 --- a/src/main/constants.ts +++ b/src/main/constants.ts @@ -12,10 +12,9 @@ export const levelDatabasePath = path.join( `hydra-db${isStaging ? "-staging" : ""}` ); -export const databaseDirectory = path.join(app.getPath("appData"), "hydra"); -export const databasePath = path.join( - databaseDirectory, - isStaging ? "hydra_test.db" : "hydra.db" +export const commonRedistPath = path.join( + app.getPath("userData"), + "CommonRedist" ); export const logsPath = path.join(app.getPath("userData"), "logs"); 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-sources.ts b/src/main/events/download-sources/create-download-sources.ts new file mode 100644 index 00000000..cf1f8f51 --- /dev/null +++ b/src/main/events/download-sources/create-download-sources.ts @@ -0,0 +1,13 @@ +import { HydraApi } from "@main/services"; +import { registerEvent } from "../register-event"; + +const createDownloadSources = async ( + _event: Electron.IpcMainInvokeEvent, + urls: string[] +) => { + await HydraApi.post("/profile/download-sources", { + urls, + }); +}; + +registerEvent("createDownloadSources", createDownloadSources); diff --git a/src/main/events/download-sources/get-download-sources.ts b/src/main/events/download-sources/get-download-sources.ts new file mode 100644 index 00000000..bbebd06c --- /dev/null +++ b/src/main/events/download-sources/get-download-sources.ts @@ -0,0 +1,8 @@ +import { HydraApi } from "@main/services"; +import { registerEvent } from "../register-event"; + +const getDownloadSources = async (_event: Electron.IpcMainInvokeEvent) => { + return HydraApi.get("/profile/download-sources"); +}; + +registerEvent("getDownloadSources", getDownloadSources); diff --git a/src/main/events/download-sources/remove-download-source.ts b/src/main/events/download-sources/remove-download-source.ts new file mode 100644 index 00000000..bcc66998 --- /dev/null +++ b/src/main/events/download-sources/remove-download-source.ts @@ -0,0 +1,18 @@ +import { HydraApi } from "@main/services"; +import { registerEvent } from "../register-event"; + +const removeDownloadSource = async ( + _event: Electron.IpcMainInvokeEvent, + url?: string, + removeAll = false +) => { + const params = new URLSearchParams({ + all: removeAll.toString(), + }); + + if (url) params.set("url", url); + + return HydraApi.delete(`/profile/download-sources?${params.toString()}`); +}; + +registerEvent("removeDownloadSource", removeDownloadSource); diff --git a/src/main/events/index.ts b/src/main/events/index.ts index bcbbb43d..8465843f 100644 --- a/src/main/events/index.ts +++ b/src/main/events/index.ts @@ -20,6 +20,7 @@ import "./library/close-game"; import "./library/delete-game-folder"; import "./library/get-game-by-object-id"; import "./library/get-library"; +import "./library/extract-game-download"; import "./library/open-game"; import "./library/open-game-executable-path"; import "./library/open-game-installer"; @@ -38,6 +39,8 @@ import "./misc/show-open-dialog"; import "./misc/get-features"; import "./misc/show-item-in-folder"; import "./misc/get-badges"; +import "./misc/install-common-redist"; +import "./misc/can-install-common-redist"; import "./torrenting/cancel-game-download"; import "./torrenting/pause-game-download"; import "./torrenting/resume-game-download"; @@ -90,6 +93,9 @@ 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-sources"; +import "./download-sources/remove-download-source"; +import "./download-sources/get-download-sources"; import { isPortableVersion } from "@main/helpers"; ipcMain.handle("ping", () => "pong"); diff --git a/src/main/events/library/extract-game-download.ts b/src/main/events/library/extract-game-download.ts new file mode 100644 index 00000000..8fb24b81 --- /dev/null +++ b/src/main/events/library/extract-game-download.ts @@ -0,0 +1,46 @@ +import { registerEvent } from "../register-event"; +import { GameShop } from "@types"; +import path from "node:path"; +import { GameFilesManager } from "@main/services"; +import { downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; +import { FILE_EXTENSIONS_TO_EXTRACT } from "@shared"; + +const extractGameDownload = async ( + _event: Electron.IpcMainInvokeEvent, + shop: GameShop, + objectId: string +): Promise => { + const gameKey = levelKeys.game(shop, objectId); + + const [download, game] = await Promise.all([ + downloadsSublevel.get(gameKey), + gamesSublevel.get(gameKey), + ]); + + if (!download || !game) return false; + + await downloadsSublevel.put(gameKey, { + ...download, + extracting: true, + }); + + const gameFilesManager = new GameFilesManager(shop, objectId); + + if ( + FILE_EXTENSIONS_TO_EXTRACT.some((ext) => download.folderName?.endsWith(ext)) + ) { + gameFilesManager.extractDownloadedFile(); + } else { + gameFilesManager + .extractFilesInDirectory( + path.join(download.downloadPath, download.folderName!) + ) + .then(() => { + gameFilesManager.setExtractionComplete(false); + }); + } + + return true; +}; + +registerEvent("extractGameDownload", extractGameDownload); diff --git a/src/main/events/library/update-executable-path.ts b/src/main/events/library/update-executable-path.ts index e753706b..c60638d7 100644 --- a/src/main/events/library/update-executable-path.ts +++ b/src/main/events/library/update-executable-path.ts @@ -21,6 +21,8 @@ const updateExecutablePath = async ( await gamesSublevel.put(gameKey, { ...game, executablePath: parsedPath, + automaticCloudSync: + executablePath === null ? false : game.automaticCloudSync, }); }; diff --git a/src/main/events/misc/can-install-common-redist.ts b/src/main/events/misc/can-install-common-redist.ts new file mode 100644 index 00000000..e2303966 --- /dev/null +++ b/src/main/events/misc/can-install-common-redist.ts @@ -0,0 +1,7 @@ +import { registerEvent } from "../register-event"; +import { CommonRedistManager } from "@main/services/common-redist-manager"; + +const canInstallCommonRedist = async (_event: Electron.IpcMainInvokeEvent) => + CommonRedistManager.canInstallCommonRedist(); + +registerEvent("canInstallCommonRedist", canInstallCommonRedist); diff --git a/src/main/events/misc/install-common-redist.ts b/src/main/events/misc/install-common-redist.ts new file mode 100644 index 00000000..34e609ec --- /dev/null +++ b/src/main/events/misc/install-common-redist.ts @@ -0,0 +1,10 @@ +import { registerEvent } from "../register-event"; +import { CommonRedistManager } from "@main/services/common-redist-manager"; + +const installCommonRedist = async (_event: Electron.IpcMainInvokeEvent) => { + if (await CommonRedistManager.canInstallCommonRedist()) { + CommonRedistManager.installCommonRedist(); + } +}; + +registerEvent("installCommonRedist", installCommonRedist); 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..93986ac2 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,18 @@ 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"; +import { CommonRedistManager } from "./services/common-redist-manager"; 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 +44,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( @@ -65,138 +66,6 @@ export const loadState = async () => { await DownloadManager.startRPC(nextItemOnQueue, downloadsToSeed); 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, - ]); + + CommonRedistManager.downloadCommonRedist(); }; diff --git a/src/main/services/7zip.ts b/src/main/services/7zip.ts new file mode 100644 index 00000000..08abf389 --- /dev/null +++ b/src/main/services/7zip.ts @@ -0,0 +1,76 @@ +import { app } from "electron"; +import cp from "node:child_process"; +import path from "node:path"; +import { logger } from "./logger"; + +export const binaryName = { + linux: "7zzs", + darwin: "7zz", + win32: "7z.exe", +}; + +export class SevenZip { + private static readonly binaryPath = app.isPackaged + ? path.join(process.resourcesPath, binaryName[process.platform]) + : path.join( + __dirname, + "..", + "..", + "binaries", + binaryName[process.platform] + ); + + public static extractFile( + { + filePath, + outputPath, + cwd, + passwords = [], + }: { + filePath: string; + outputPath?: string; + cwd?: string; + passwords?: string[]; + }, + successCb: () => void, + errorCb: () => void + ) { + const tryPassword = (index = -1) => { + const password = passwords[index] ?? ""; + logger.info(`Trying password ${password} on ${filePath}`); + + const args = ["x", filePath, "-y", "-p" + password]; + + if (outputPath) { + args.push("-o" + outputPath); + } + + const child = cp.execFile(this.binaryPath, args, { + cwd, + }); + + child.once("exit", (code) => { + console.log("EXIT CALLED", code, filePath); + + if (code === 0) { + successCb(); + return; + } + + if (index < passwords.length - 1) { + logger.info( + `Failed to extract file: ${filePath} with password: ${password}. Trying next password...` + ); + + tryPassword(index + 1); + } else { + logger.info(`Failed to extract file: ${filePath}`); + + errorCb(); + } + }); + }; + + tryPassword(); + } +} diff --git a/src/main/services/aria2.ts b/src/main/services/aria2.ts index b7aa539c..a927a1bd 100644 --- a/src/main/services/aria2.ts +++ b/src/main/services/aria2.ts @@ -2,23 +2,26 @@ 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", "--file-allocation=none", "--allow-overwrite=true", + "-s", + "16", + "-x", + "16", + "-k", + "1M", ], { stdio: "inherit", windowsHide: true } ); diff --git a/src/main/services/common-redist-manager.ts b/src/main/services/common-redist-manager.ts new file mode 100644 index 00000000..2a08bfab --- /dev/null +++ b/src/main/services/common-redist-manager.ts @@ -0,0 +1,109 @@ +import { commonRedistPath } from "@main/constants"; +import axios from "axios"; +import fs from "node:fs"; +import cp from "node:child_process"; +import path from "node:path"; +import { logger } from "./logger"; +import { app } from "electron"; +import { WindowManager } from "./window-manager"; + +export class CommonRedistManager { + private static readonly redistributables = [ + "dotNetFx40_Full_setup.exe", + "dxwebsetup.exe", + "oalinst.exe", + "install.bat", + "vcredist_2015-2019_x64.exe", + "vcredist_2015-2019_x86.exe", + "vcredist_x64.exe", + "vcredist_x86.exe", + "xnafx40_redist.msi", + ]; + private static readonly installationTimeout = 1000 * 60 * 5; // 5 minutes + private static readonly installationLog = path.join( + app.getPath("temp"), + "common_redist_install.log" + ); + + public static async installCommonRedist() { + const abortController = new AbortController(); + const timeout = setTimeout(() => { + abortController.abort(); + logger.error("Installation timed out"); + + WindowManager.mainWindow?.webContents.send("common-redist-progress", { + log: "Installation timed out", + complete: false, + }); + }, this.installationTimeout); + + const installationCompleteMessage = "Installation complete"; + + if (!fs.existsSync(this.installationLog)) { + await fs.promises.writeFile(this.installationLog, ""); + } + + fs.watch(this.installationLog, { signal: abortController.signal }, () => { + fs.readFile(this.installationLog, "utf-8", (err, data) => { + if (err) return logger.error("Error reading log file:", err); + + const tail = data.split("\n").at(-2)?.trim(); + + if (tail?.includes(installationCompleteMessage)) { + clearTimeout(timeout); + if (!abortController.signal.aborted) { + abortController.abort(); + } + } + + WindowManager.mainWindow?.webContents.send("common-redist-progress", { + log: tail, + complete: tail?.includes(installationCompleteMessage), + }); + }); + }); + + cp.exec( + path.join(commonRedistPath, "install.bat"), + { + windowsHide: true, + }, + (error) => { + if (error) { + logger.error("Failed to run install.bat", error); + } + } + ); + } + + public static async canInstallCommonRedist() { + return this.redistributables.every((redist) => { + const filePath = path.join(commonRedistPath, redist); + + return fs.existsSync(filePath); + }); + } + + public static async downloadCommonRedist() { + if (!fs.existsSync(commonRedistPath)) { + await fs.promises.mkdir(commonRedistPath, { recursive: true }); + } + + for (const redist of this.redistributables) { + const filePath = path.join(commonRedistPath, redist); + + if (fs.existsSync(filePath)) { + continue; + } + + const response = await axios.get( + `https://github.com/hydralauncher/hydra-common-redist/raw/refs/heads/main/${redist}`, + { + responseType: "arraybuffer", + } + ); + + await fs.promises.writeFile(filePath, response.data); + } + } +} diff --git a/src/main/services/download/download-manager.ts b/src/main/services/download/download-manager.ts index 860cf5cf..9eba39f3 100644 --- a/src/main/services/download/download-manager.ts +++ b/src/main/services/download/download-manager.ts @@ -1,4 +1,4 @@ -import { Downloader, DownloadError } from "@shared"; +import { Downloader, DownloadError, FILE_EXTENSIONS_TO_EXTRACT } from "@shared"; import { WindowManager } from "../window-manager"; import { publishDownloadCompleteNotification } from "../notifications"; import type { Download, DownloadProgress, UserPreferences } from "@types"; @@ -22,6 +22,7 @@ import { logger } from "../logger"; import { db, downloadsSublevel, gamesSublevel, levelKeys } from "@main/level"; import { sortBy } from "lodash-es"; import { TorBoxClient } from "./torbox"; +import { GameFilesManager } from "../game-files-manager"; export class DownloadManager { private static downloadingGameId: string | null = null; @@ -136,6 +137,8 @@ export class DownloadManager { ); } + const shouldExtract = download.automaticallyExtract; + if (progress === 1 && download) { publishDownloadCompleteNotification(game); @@ -143,23 +146,48 @@ export class DownloadManager { userPreferences?.seedAfterDownloadComplete && download.downloader === Downloader.Torrent ) { - downloadsSublevel.put(gameId, { + await downloadsSublevel.put(gameId, { ...download, status: "seeding", shouldSeed: true, queued: false, + extracting: shouldExtract, }); } else { - downloadsSublevel.put(gameId, { + await downloadsSublevel.put(gameId, { ...download, status: "complete", shouldSeed: false, queued: false, + extracting: shouldExtract, }); this.cancelDownload(gameId); } + if (shouldExtract) { + const gameFilesManager = new GameFilesManager( + game.shop, + game.objectId + ); + + if ( + FILE_EXTENSIONS_TO_EXTRACT.some((ext) => + download.folderName?.endsWith(ext) + ) + ) { + gameFilesManager.extractDownloadedFile(); + } else { + gameFilesManager + .extractFilesInDirectory( + path.join(download.downloadPath, download.folderName!) + ) + .then(() => { + gameFilesManager.setExtractionComplete(); + }); + } + } + const downloads = await downloadsSublevel .values() .all() diff --git a/src/main/services/game-files-manager.ts b/src/main/services/game-files-manager.ts new file mode 100644 index 00000000..120b3e8f --- /dev/null +++ b/src/main/services/game-files-manager.ts @@ -0,0 +1,158 @@ +import path from "node:path"; +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 { WindowManager } from "./window-manager"; +import { publishExtractionCompleteNotification } from "./notifications"; +import { logger } from "./logger"; + +export class GameFilesManager { + 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); + + await downloadsSublevel.put(gameKey, { + ...download!, + extracting: false, + }); + + WindowManager.mainWindow?.webContents.send( + "on-extraction-complete", + this.shop, + this.objectId + ); + } + + async extractFilesInDirectory(directoryPath: string) { + if (!fs.existsSync(directoryPath)) return; + const files = await fs.promises.readdir(directoryPath); + + const compressedFiles = files.filter((file) => + FILE_EXTENSIONS_TO_EXTRACT.some((ext) => file.endsWith(ext)) + ); + + const filesToExtract = compressedFiles.filter( + (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(); + } + ); + }); + }) + ); + + compressedFiles.forEach((file) => { + 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(); + } + }); + } + }); + } + + async setExtractionComplete(publishNotification = true) { + const gameKey = levelKeys.game(this.shop, this.objectId); + + const [download, game] = await Promise.all([ + downloadsSublevel.get(gameKey), + gamesSublevel.get(gameKey), + ]); + + await downloadsSublevel.put(gameKey, { + ...download!, + extracting: false, + }); + + WindowManager.mainWindow?.webContents.send( + "on-extraction-complete", + this.shop, + this.objectId + ); + + if (publishNotification) { + publishExtractionCompleteNotification(game!); + } + } + + async extractDownloadedFile() { + const gameKey = levelKeys.game(this.shop, this.objectId); + + const [download, game] = await Promise.all([ + downloadsSublevel.get(gameKey), + gamesSublevel.get(gameKey), + ]); + + if (!download || !game) return false; + + const filePath = path.join(download.downloadPath, download.folderName!); + + const extractionPath = path.join( + download.downloadPath, + path.parse(download.folderName!).name + ); + + SevenZip.extractFile( + { + filePath, + outputPath: extractionPath, + passwords: ["online-fix.me", "steamrip.com"], + }, + async () => { + 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(); + } + }); + } + + await downloadsSublevel.put(gameKey, { + ...download!, + folderName: path.parse(download.folderName!).name, + }); + + this.setExtractionComplete(); + }, + () => { + this.clearExtractionState(); + } + ); + + return true; + } +} diff --git a/src/main/services/index.ts b/src/main/services/index.ts index f53dae31..30b502f5 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -8,3 +8,6 @@ export * from "./main-loop"; export * from "./hydra-api"; export * from "./ludusavi"; export * from "./cloud-sync"; +export * from "./7zip"; +export * from "./game-files-manager"; +export * from "./common-redist-manager"; 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 b49595a7..280c0cc4 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -100,6 +100,11 @@ contextBridge.exposeInMainWorld("electron", { /* Download sources */ putDownloadSource: (objectIds: string[]) => ipcRenderer.invoke("putDownloadSource", objectIds), + createDownloadSources: (urls: string[]) => + ipcRenderer.invoke("createDownloadSources", urls), + removeDownloadSource: (url: string, removeAll?: boolean) => + ipcRenderer.invoke("removeDownloadSource", url, removeAll), + getDownloadSources: () => ipcRenderer.invoke("getDownloadSources"), /* Library */ toggleAutomaticCloudSync: ( @@ -173,6 +178,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("getGameByObjectId", shop, objectId), resetGameAchievements: (shop: GameShop, objectId: string) => ipcRenderer.invoke("resetGameAchievements", shop, objectId), + extractGameDownload: (shop: GameShop, objectId: string) => + ipcRenderer.invoke("extractGameDownload", shop, objectId), onGamesRunning: ( cb: ( gamesRunning: Pick[] @@ -195,6 +202,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) => @@ -279,6 +295,8 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.invoke("showItemInFolder", path), getFeatures: () => ipcRenderer.invoke("getFeatures"), getBadges: () => ipcRenderer.invoke("getBadges"), + canInstallCommonRedist: () => ipcRenderer.invoke("canInstallCommonRedist"), + installCommonRedist: () => ipcRenderer.invoke("installCommonRedist"), platform: process.platform, /* Auto update */ @@ -294,6 +312,16 @@ contextBridge.exposeInMainWorld("electron", { ipcRenderer.removeListener("autoUpdaterEvent", listener); }; }, + onCommonRedistProgress: ( + cb: (value: { log: string; complete: boolean }) => void + ) => { + const listener = ( + _event: Electron.IpcRendererEvent, + value: { log: string; complete: boolean } + ) => cb(value); + ipcRenderer.on("common-redist-progress", listener); + return () => ipcRenderer.removeListener("common-redist-progress", listener); + }, checkForUpdates: () => ipcRenderer.invoke("checkForUpdates"), restartAndInstallUpdate: () => ipcRenderer.invoke("restartAndInstallUpdate"), diff --git a/src/renderer/src/app.tsx b/src/renderer/src/app.tsx index f9bd645e..b1867279 100644 --- a/src/renderer/src/app.tsx +++ b/src/renderer/src/app.tsx @@ -31,6 +31,7 @@ import { HydraCloudModal } from "./pages/shared-modals/hydra-cloud/hydra-cloud-m import { injectCustomCss } from "./helpers"; import "./app.scss"; +import { DownloadSource } from "@types"; export interface AppProps { children: React.ReactNode; @@ -136,6 +137,70 @@ export function App() { }); }, [fetchUserDetails, updateUserDetails, dispatch]); + const syncDownloadSources = useCallback(async () => { + const downloadSources = await window.electron.getDownloadSources(); + + 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) => { + const existingDownloadSource = existingDownloadSources.find( + (downloadSource) => downloadSource.url === source.url + ); + + if (!existingDownloadSource) { + const channel = new BroadcastChannel( + `download_sources:import:${source.url}` + ); + + downloadSourcesWorker.postMessage([ + "IMPORT_DOWNLOAD_SOURCE", + source.url, + ]); + + channel.onmessage = () => { + resolve(true); + channel.close(); + }; + } else { + resolve(true); + } + }); + }) + ); + + updateRepacks(); + + const id = crypto.randomUUID(); + const channel = new BroadcastChannel(`download_sources:sync:${id}`); + + channel.onmessage = async (event: MessageEvent) => { + const newRepacksCount = event.data; + window.electron.publishNewRepacksNotification(newRepacksCount); + updateRepacks(); + + const downloadSources = await downloadSourcesTable.toArray(); + + downloadSources + .filter((source) => !source.fingerprint) + .forEach(async (downloadSource) => { + const { fingerprint } = await window.electron.putDownloadSource( + downloadSource.objectIds + ); + + downloadSourcesTable.update(downloadSource.id, { fingerprint }); + }); + }; + + downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); + }, [updateRepacks]); + const onSignIn = useCallback(() => { fetchUserDetails().then((response) => { if (response) { @@ -144,7 +209,15 @@ export function App() { showSuccessToast(t("successfully_signed_in")); } }); - }, [fetchUserDetails, t, showSuccessToast, updateUserDetails]); + + syncDownloadSources(); + }, [ + fetchUserDetails, + t, + showSuccessToast, + updateUserDetails, + syncDownloadSources, + ]); useEffect(() => { const unsubscribe = window.electron.onSyncFriendRequests((result) => { @@ -212,31 +285,8 @@ export function App() { }, [dispatch, draggingDisabled]); useEffect(() => { - updateRepacks(); - - const id = crypto.randomUUID(); - const channel = new BroadcastChannel(`download_sources:sync:${id}`); - - channel.onmessage = async (event: MessageEvent) => { - const newRepacksCount = event.data; - window.electron.publishNewRepacksNotification(newRepacksCount); - updateRepacks(); - - const downloadSources = await downloadSourcesTable.toArray(); - - downloadSources - .filter((source) => !source.fingerprint) - .forEach(async (downloadSource) => { - const { fingerprint } = await window.electron.putDownloadSource( - downloadSource.objectIds - ); - - downloadSourcesTable.update(downloadSource.id, { fingerprint }); - }); - }; - - downloadSourcesWorker.postMessage(["SYNC_DOWNLOAD_SOURCES", id]); - }, [updateRepacks]); + syncDownloadSources(); + }, [syncDownloadSources]); useEffect(() => { const loadAndApplyTheme = async () => { diff --git a/src/renderer/src/components/bottom-panel/bottom-panel.tsx b/src/renderer/src/components/bottom-panel/bottom-panel.tsx index 16f1de06..2c32c5da 100644 --- a/src/renderer/src/components/bottom-panel/bottom-panel.tsx +++ b/src/renderer/src/components/bottom-panel/bottom-panel.tsx @@ -1,7 +1,12 @@ import { useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useDownload, useLibrary, useUserDetails } from "@renderer/hooks"; +import { + useDownload, + useLibrary, + useToast, + useUserDetails, +} from "@renderer/hooks"; import "./bottom-panel.scss"; @@ -17,20 +22,52 @@ export function BottomPanel() { const { library } = useLibrary(); + const { showSuccessToast } = useToast(); + const { lastPacket, progress, downloadSpeed, eta } = useDownload(); const [version, setVersion] = useState(""); const [sessionHash, setSessionHash] = useState(""); + const [commonRedistStatus, setCommonRedistStatus] = useState( + null + ); useEffect(() => { window.electron.getVersion().then((result) => setVersion(result)); }, []); + useEffect(() => { + const unlisten = window.electron.onCommonRedistProgress( + ({ log, complete }) => { + if (log === "Installation timed out" || complete) { + setCommonRedistStatus(null); + + if (complete) { + showSuccessToast( + t("installation_complete"), + t("installation_complete_message") + ); + } + + return; + } + + setCommonRedistStatus(log); + } + ); + + return () => unlisten(); + }, [t, showSuccessToast]); + useEffect(() => { window.electron.getSessionHash().then((result) => setSessionHash(result)); }, [userDetails?.id]); const status = useMemo(() => { + if (commonRedistStatus) { + return t("installing_common_redist", { log: commonRedistStatus }); + } + const game = lastPacket ? library.find((game) => game.id === lastPacket?.gameId) : undefined; @@ -64,7 +101,15 @@ export function BottomPanel() { } return t("no_downloads_in_progress"); - }, [t, library, lastPacket, progress, eta, downloadSpeed]); + }, [ + t, + library, + lastPacket, + progress, + eta, + downloadSpeed, + commonRedistStatus, + ]); return (