Merge branch 'hydralauncher:main' into translation-tr

This commit is contained in:
Spydea
2025-04-08 18:57:52 +03:00
committed by GitHub
59 changed files with 1025 additions and 593 deletions

View File

@@ -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",

View File

@@ -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",

View File

@@ -10,7 +10,7 @@
"hot": "Сейчас популярно",
"start_typing": "Начинаю вводить текст...",
"weekly": "📅 Лучшие игры недели",
"achievements": "🏆 Игры, в которых нужно победить"
"achievements": "🏆 Игры с достижениями"
},
"sidebar": {
"catalogue": "Каталог",
@@ -36,7 +36,7 @@
"downloads": "Загрузки",
"search_results": "Результаты поиска",
"settings": "Настройки",
"version_available_install": "Доступна версия {{version}}. Нажмите здесь для перезапуска и установки.",
"version_available_install": "Доступна версия {{version}}. Нажмите здесь для установки.",
"version_available_download": "Доступна версия {{version}}. Нажмите здесь для загрузки."
},
"bottom_panel": {
@@ -50,7 +50,7 @@
"search": "Фильтр…",
"developers": "Разработчики",
"genres": "Жанры",
"tags": "Маркеры",
"tags": "Теги",
"publishers": "Издательства",
"download_sources": "Источники загрузки",
"result_count": "{{resultCount}} результатов",
@@ -183,13 +183,15 @@
"custom_backup_location_set": "Установлено настраиваемое местоположение резервной копии",
"no_directory_selected": "Не выбран каталог",
"no_write_permission": "Невозможно загрузить в эту директорию. Нажмите здесь, чтобы узнать больше.",
"reset_achievements": "Сброс достижений",
"reset_achievements_description": "Это сбросит все достижения для {{game}}",
"reset_achievements_title": "Вы уверены?",
"reset_achievements_success": "Достижения успешно сброшены",
"reset_achievements_error": "Не удалось сбросить достижения",
"download_error_gofile_quota_exceeded": "Вы превысили месячную квоту Gofile. Пожалуйста, подождите, пока квота не будет восстановлена.",
"download_error_real_debrid_account_not_authorized": "Ваш аккаунт Real-Debrid не авторизован для осуществления новых загрузок. Пожалуйста, проверьте настройки учетной записи и повторите попытку.",
"download_error_not_cached_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, а опрос статуса загрузки с Real-Debrid пока недоступен.",
"download_error_not_cached_in_torbox": "Эта загрузка недоступна на Torbox, и опросить статус загрузки с Torbox пока невозможно.",
"download_error_not_cached_in_real_debrid": "Эта загрузка недоступна на Real-Debrid, и получение статуса загрузки с Real-Debrid пока недоступно.",
"download_error_not_cached_in_torbox": "Эта загрузка недоступна на Torbox, и получить статус загрузки с Torbox пока невозможно.",
"game_added_to_favorites": "Игра добавлена в избранное",
"game_removed_from_favorites": "Игра удалена из избранного"
},
@@ -267,15 +269,15 @@
"download_source_up_to_date": "Обновлён",
"download_source_errored": "Ошибка",
"sync_download_sources": "Обновить источники",
"removed_download_source": "Источник загрузок удален",
"removed_download_source": "Источник удален",
"cancel_button_confirmation_delete_all_sources": "Нет",
"confirm_button_confirmation_delete_all_sources": "Да, удалить все",
"description_confirmation_delete_all_sources": "Вы удалите все источники загрузки",
"title_confirmation_delete_all_sources": "Удалить все источники загрузки",
"removed_download_sources": "Шрифты удалены",
"button_delete_all_sources": "Удалить все источники загрузки",
"added_download_source": "Источник загрузок добавлен",
"download_sources_synced": "Все источники загрузок синхронизированы",
"description_confirmation_delete_all_sources": "Вы удалите все источники",
"title_confirmation_delete_all_sources": "Удалить все источники",
"removed_download_sources": "Источники удалены",
"button_delete_all_sources": "Удалить все источники",
"added_download_source": "Источник добавлен",
"download_sources_synced": "Все источники обновлены",
"insert_valid_json_url": "Вставьте действительный URL JSON-файла",
"found_download_option_zero": "Не найдено вариантов загрузки",
"found_download_option_one": "Найден {{countFormatted}} вариант загрузки",
@@ -283,7 +285,7 @@
"import": "Импортировать",
"blocked_users": "Заблокированные пользователи",
"friends_only": "Только для друзей",
"must_be_valid_url": "Источник должен быть действительным URL-адресом.",
"must_be_valid_url": "У источника должен быть правильный URL",
"privacy": "Конфиденциальность",
"private": "Частный",
"profile_visibility": "Видимость профиля",
@@ -334,6 +336,8 @@
"torbox_description": "TorBox - это ваш премиум-сервис, конкурирующий даже с лучшими серверами на рынке.",
"torbox_account_linked": "Аккаунт TorBox привязан",
"real_debrid_account_linked": "Аккаунт Real-Debrid привязан",
"create_real_debrid_account": "Нажмите здесь, если у вас еще нет аккаунта Real-Debrid",
"create_torbox_account": "Нажмите здесь, если у вас еще нет учетной записи TorBox",
"name_min_length": "Название темы должно содержать не менее 3 символов",
"import_theme": "Импортировать тему",
"import_theme_description": "Вы импортируете {{theme}} из магазина тем",
@@ -446,11 +450,13 @@
"uploading_banner": "Загрузка баннера...",
"background_image_updated": "Фоновое изображение обновлено",
"stats": "Статистика",
"achievements": "Достижения",
"games": "Игры",
"top_percentile": "Топ {{percentile}}%",
"ranking_updated_weekly": "Рейтинг обновляется еженедельно",
"playing": "Играет в {{game}}",
"achievements_unlocked": "Достижения разблокированы",
"earned_points": "Заработано очков:",
"show_achievements_on_profile": "Покажите свои достижения в профиле",
"show_points_on_profile": "Показывать заработанные очки в своем профиле"
},

View File

@@ -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");

View File

@@ -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),
})
);
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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");

View File

@@ -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<boolean> => {
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);

View File

@@ -21,6 +21,8 @@ const updateExecutablePath = async (
await gamesSublevel.put(gameKey, {
...game,
executablePath: parsedPath,
automaticCloudSync:
executablePath === null ? false : game.automaticCloudSync,
});
};

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 {

View File

@@ -23,10 +23,6 @@ const updateUserPreferences = async (
patchUserProfile({ language: preferences.language }).catch(() => {});
}
if (!preferences.downloadsPath) {
preferences.downloadsPath = null;
}
await db.put<string, UserPreferences>(
levelKeys.userPreferences,
{

View File

@@ -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,
},
});

View File

@@ -1,10 +0,0 @@
const config = {
development: {
migrations: {
extension: "ts",
stub: "migrations/migration.stub",
},
},
};
export default config;

View File

@@ -13,6 +13,5 @@ export const levelKeys = {
downloads: "downloads",
userPreferences: "userPreferences",
language: "language",
sqliteMigrationDone: "sqliteMigrationDone",
screenState: "screenState",
};

View File

@@ -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<string, boolean>(levelKeys.sqliteMigrationDone, true, {
const userPreferences = await db.get<string, UserPreferences | null>(
levelKeys.userPreferences,
{
valueEncoding: "json",
});
return db.get<string, UserPreferences | null>(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<string, UserPreferences>(
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<string, string>(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<string, User>(
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<string, Auth>(
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();
};

76
src/main/services/7zip.ts Normal file
View File

@@ -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();
}
}

View File

@@ -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 }
);

View File

@@ -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);
}
}
}

View File

@@ -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()

View File

@@ -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;
}
}

View File

@@ -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";

View File

@@ -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;

View File

@@ -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),
})
);
}

View File

@@ -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<GameRunning, "id" | "sessionDurationInMillis">[]
@@ -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"),

View File

@@ -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<number>) => {
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<number>) => {
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 () => {

View File

@@ -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<null | string>("");
const [commonRedistStatus, setCommonRedistStatus] = useState<string | null>(
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 (
<footer className="bottom-panel">

View File

@@ -149,6 +149,8 @@ declare global {
onLibraryBatchComplete: (cb: () => void) => () => Electron.IpcRenderer;
resetGameAchievements: (shop: GameShop, objectId: string) => Promise<void>;
/* User preferences */
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
getUserPreferences: () => Promise<UserPreferences | null>;
updateUserPreferences: (
preferences: Partial<UserPreferences>
@@ -157,14 +159,21 @@ declare global {
enabled: boolean;
minimized: boolean;
}) => Promise<void>;
authenticateRealDebrid: (apiToken: string) => Promise<RealDebridUser>;
authenticateTorBox: (apiToken: string) => Promise<TorBoxUser>;
extractGameDownload: (shop: GameShop, objectId: string) => Promise<boolean>;
onAchievementUnlocked: (cb: () => void) => () => Electron.IpcRenderer;
onExtractionComplete: (
cb: (shop: GameShop, objectId: string) => void
) => () => Electron.IpcRenderer;
/* Download sources */
putDownloadSource: (
objectIds: string[]
) => Promise<{ fingerprint: string }>;
createDownloadSources: (urls: string[]) => Promise<void>;
removeDownloadSource: (url: string, removeAll?: boolean) => Promise<void>;
getDownloadSources: () => Promise<
Pick<DownloadSource, "url" | "createdAt" | "updatedAt">[]
>;
/* Hardware */
getDiskFreeSpace: (path: string) => Promise<disk.DiskUsage>;
@@ -225,6 +234,11 @@ declare global {
showItemInFolder: (path: string) => Promise<void>;
getFeatures: () => Promise<string[]>;
getBadges: () => Promise<Badge[]>;
canInstallCommonRedist: () => Promise<boolean>;
installCommonRedist: () => Promise<void>;
onCommonRedistProgress: (
cb: (value: { log: string; complete: boolean }) => void
) => () => Electron.IpcRenderer;
platform: NodeJS.Platform;
/* Auto update */

View File

@@ -21,9 +21,9 @@ export interface CatalogueCache {
export const db = new Dexie("Hydra");
db.version(8).stores({
db.version(9).stores({
repacks: `++id, title, uris, fileSize, uploadDate, downloadSourceId, repacker, objectIds, createdAt, updatedAt`,
downloadSources: `++id, url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
downloadSources: `++id, &url, name, etag, objectIds, downloadCount, status, fingerprint, createdAt, updatedAt`,
howLongToBeatEntries: `++id, categories, [shop+objectId], createdAt, updatedAt`,
});

View File

@@ -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),
};
}

View File

@@ -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 (
<div className="pagination">
{/* Previous Button */}
<Button
theme="outline"
onClick={() => onPageChange(page - 1)}
@@ -45,7 +41,6 @@ export function Pagination({
{page > 2 && (
<>
{/* initial page */}
<Button
theme="outline"
onClick={() => onPageChange(1)}
@@ -55,14 +50,12 @@ export function Pagination({
{1}
</Button>
{/* ellipsis */}
<div className="pagination__ellipsis">
<span className="pagination__ellipsis-text">...</span>
</div>
</>
)}
{/* Page Buttons */}
{Array.from(
{ length: endPage - startPage + 1 },
(_, i) => startPage + i
@@ -79,12 +72,10 @@ export function Pagination({
{page < totalPages - 1 && (
<>
{/* ellipsis */}
<div className="pagination__ellipsis">
<span className="pagination__ellipsis-text">...</span>
</div>
{/* last page */}
<Button
theme="outline"
onClick={() => onPageChange(totalPages)}
@@ -96,7 +87,6 @@ export function Pagination({
</>
)}
{/* Next Button */}
<Button
theme="outline"
onClick={() => onPageChange(page + 1)}

View File

@@ -10,11 +10,11 @@ import {
import { Downloader, formatBytes, steamUrlBuilder } from "@shared";
import { DOWNLOADER_NAME } from "@renderer/constants";
import { useAppSelector, useDownload } from "@renderer/hooks";
import { useAppSelector, useDownload, useLibrary } from "@renderer/hooks";
import "./download-group.scss";
import { useTranslation } from "react-i18next";
import { useMemo } from "react";
import { useCallback, useMemo } from "react";
import {
DropdownMenu,
DropdownMenuItem,
@@ -22,6 +22,7 @@ import {
import {
ColumnsIcon,
DownloadIcon,
FileDirectoryIcon,
LinkIcon,
PlayIcon,
QuestionIcon,
@@ -56,6 +57,8 @@ export function DownloadGroup({
(state) => state.userPreferences.value
);
const { updateLibrary } = useLibrary();
const {
lastPacket,
progress,
@@ -89,6 +92,14 @@ export function DownloadGroup({
return map;
}, [seedingStatus]);
const extractGameDownload = useCallback(
async (shop: GameShop, objectId: string) => {
await window.electron.extractGameDownload(shop, objectId);
updateLibrary();
},
[updateLibrary]
);
const getGameInfo = (game: LibraryGame) => {
const download = game.download!;
@@ -96,6 +107,10 @@ export function DownloadGroup({
const finalDownloadSize = getFinalDownloadSize(game);
const seedingStatus = seedingMap.get(game.id);
if (download.extracting) {
return <p>{t("extracting")}</p>;
}
if (isGameDeleting(game.id)) {
return <p>{t("deleting")}</p>;
}
@@ -197,6 +212,14 @@ export function DownloadGroup({
},
icon: <DownloadIcon />,
},
{
label: t("extract"),
disabled: game.download.extracting,
icon: <FileDirectoryIcon />,
onClick: () => {
extractGameDownload(game.shop, game.objectId);
},
},
{
label: t("stop_seeding"),
disabled: deleting,

View File

@@ -38,7 +38,13 @@ export default function Downloads() {
useEffect(() => {
window.electron.onSeedingStatus((value) => setSeedingStatus(value));
}, []);
const unsubscribe = window.electron.onExtractionComplete(() => {
updateLibrary();
});
return () => unsubscribe();
}, [updateLibrary]);
const handleOpenGameInstaller = (shop: GameShop, objectId: string) =>
window.electron.openGameInstaller(shop, objectId).then((isBinaryInPath) => {
@@ -67,7 +73,7 @@ export default function Downloads() {
if (!next.download) return prev;
/* Is downloading */
if (lastPacket?.gameId === next.id)
if (lastPacket?.gameId === next.id || next.download.extracting)
return { ...prev, downloading: [...prev.downloading, next] };
/* Is either queued or paused */

View File

@@ -4,7 +4,6 @@ import { cloudSyncContext, gameDetailsContext } from "@renderer/context";
import "./cloud-sync-modal.scss";
import { formatBytes } from "@shared";
import { format } from "date-fns";
import {
ClockIcon,
DeviceDesktopIcon,
@@ -14,7 +13,7 @@ import {
TrashIcon,
UploadIcon,
} from "@primer/octicons-react";
import { useAppSelector, useToast } from "@renderer/hooks";
import { useAppSelector, useDate, useToast } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
import { AxiosProgressEvent } from "axios";
import { formatDownloadProgress } from "@renderer/helpers";
@@ -29,6 +28,8 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
const { t } = useTranslation("game_details");
const { formatDate, formatDateTime } = useDate();
const {
artifacts,
backupPreview,
@@ -205,7 +206,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<h3>
{artifact.label ??
t("backup_from", {
date: format(artifact.createdAt, "dd/MM/yyyy"),
date: formatDate(artifact.createdAt),
})}
</h3>
<small>{formatBytes(artifact.artifactLengthInBytes)}</small>
@@ -223,7 +224,7 @@ export function CloudSyncModal({ visible, onClose }: CloudSyncModalProps) {
<span className="cloud-sync-modal__artifact-meta">
<ClockIcon size={14} />
{format(artifact.createdAt, "dd/MM/yyyy HH:mm:ss")}
{formatDateTime(artifact.createdAt)}
</span>
</div>

View File

@@ -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) {

View File

@@ -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<number | null>(null);
const [selectedPath, setSelectedPath] = useState("");
const [downloadStarting, setDownloadStarting] = useState(false);
const [automaticExtractionEnabled, setAutomaticExtractionEnabled] =
useState(true);
const [selectedDownloader, setSelectedDownloader] =
useState<Downloader | null>(null);
const [hasWritePermission, setHasWritePermission] = useState<boolean | null>(
@@ -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,14 @@ export function DownloadSettingsModal({
</p>
</div>
<CheckboxField
label={t("automatically_extract_downloaded_files")}
checked={automaticExtractionEnabled}
onChange={() =>
setAutomaticExtractionEnabled(!automaticExtractionEnabled)
}
/>
<Button
onClick={handleStartClick}
disabled={

View File

@@ -134,6 +134,7 @@ export function GameOptionsModal({
const handleClearExecutablePath = async () => {
await window.electron.updateExecutablePath(game.shop, game.objectId, null);
updateGame();
};

View File

@@ -16,7 +16,8 @@ export interface RepacksModalProps {
startDownload: (
repack: GameRepack,
downloader: Downloader,
downloadPath: string
downloadPath: string,
automaticallyExtract: boolean
) => Promise<{ ok: boolean; error?: string }>;
onClose: () => void;
}

View File

@@ -119,7 +119,8 @@ export function AddDownloadSourceModal({
downloadSourcesWorker.postMessage(["IMPORT_DOWNLOAD_SOURCE", url]);
channel.onmessage = async () => {
channel.onmessage = () => {
window.electron.createDownloadSources([url]);
setIsLoading(false);
putDownloadSource();

View File

@@ -70,14 +70,20 @@ export function SettingsDownloadSources() {
if (sourceUrl) setShowAddDownloadSourceModal(true);
}, [sourceUrl]);
const handleRemoveSource = (id: number) => {
const handleRemoveSource = (downloadSource: DownloadSource) => {
setIsRemovingDownloadSource(true);
const channel = new BroadcastChannel(`download_sources:delete:${id}`);
const channel = new BroadcastChannel(
`download_sources:delete:${downloadSource.id}`
);
downloadSourcesWorker.postMessage(["DELETE_DOWNLOAD_SOURCE", id]);
downloadSourcesWorker.postMessage([
"DELETE_DOWNLOAD_SOURCE",
downloadSource.id,
]);
channel.onmessage = () => {
showSuccessToast(t("removed_download_source"));
window.electron.removeDownloadSource(downloadSource.url);
getDownloadSources();
setIsRemovingDownloadSource(false);
@@ -96,7 +102,7 @@ export function SettingsDownloadSources() {
channel.onmessage = () => {
showSuccessToast(t("removed_download_sources"));
window.electron.removeDownloadSource("", true);
getDownloadSources();
setIsRemovingDownloadSource(false);
setShowConfirmationDeleteAllSourcesModal(false);
@@ -253,7 +259,7 @@ export function SettingsDownloadSources() {
<Button
type="button"
theme="outline"
onClick={() => handleRemoveSource(downloadSource.id)}
onClick={() => handleRemoveSource(downloadSource)}
disabled={isRemovingDownloadSource}
>
<NoEntryIcon />

View File

@@ -5,8 +5,12 @@
flex-direction: column;
gap: globals.$spacing-unit * 2;
&__notifications-title {
&__section-title {
margin-top: calc(globals.$spacing-unit * 2);
margin-bottom: globals.$spacing-unit;
}
&__common-redist-button {
align-self: flex-start;
}
}

View File

@@ -12,6 +12,8 @@ import languageResources from "@locales";
import { orderBy } from "lodash-es";
import { settingsContext } from "@renderer/context";
import "./settings-general.scss";
import { DesktopDownloadIcon } from "@primer/octicons-react";
import { logger } from "@renderer/logger";
interface LanguageOption {
option: string;
@@ -27,6 +29,9 @@ export function SettingsGeneral() {
(state) => state.userPreferences.value
);
const [canInstallCommonRedist, setCanInstallCommonRedist] = useState(false);
const [installingCommonRedist, setInstallingCommonRedist] = useState(false);
const [form, setForm] = useState({
downloadsPath: "",
downloadNotificationsEnabled: false,
@@ -47,6 +52,16 @@ export function SettingsGeneral() {
setDefaultDownloadsPath(path);
});
window.electron.canInstallCommonRedist().then((canInstall) => {
setCanInstallCommonRedist(canInstall);
});
const interval = setInterval(() => {
window.electron.canInstallCommonRedist().then((canInstall) => {
setCanInstallCommonRedist(canInstall);
});
}, 1000 * 5);
setLanguageOptions(
orderBy(
Object.entries(languageResources).map(([language, value]) => {
@@ -59,6 +74,10 @@ export function SettingsGeneral() {
"asc"
)
);
return () => {
clearInterval(interval);
};
}, []);
useEffect(() => {
@@ -90,7 +109,9 @@ export function SettingsGeneral() {
}
}, [userPreferences, defaultDownloadsPath]);
const handleLanguageChange = (event) => {
const handleLanguageChange = (
event: React.ChangeEvent<HTMLSelectElement>
) => {
const value = event.target.value;
handleChange({ language: value });
@@ -114,6 +135,28 @@ export function SettingsGeneral() {
}
};
useEffect(() => {
const unlisten = window.electron.onCommonRedistProgress(
({ log, complete }) => {
if (log === "Installation timed out" || complete) {
setInstallingCommonRedist(false);
}
}
);
return () => unlisten();
}, []);
const handleInstallCommonRedist = async () => {
setInstallingCommonRedist(true);
try {
await window.electron.installCommonRedist();
} catch (err) {
logger.error(err);
setInstallingCommonRedist(false);
}
};
return (
<div className="settings-general">
<TextField
@@ -139,9 +182,7 @@ export function SettingsGeneral() {
}))}
/>
<p className="settings-general__notifications-title">
{t("notifications")}
</p>
<h2 className="settings-general__section-title">{t("notifications")}</h2>
<CheckboxField
label={t("enable_download_notifications")}
@@ -185,6 +226,23 @@ export function SettingsGeneral() {
})
}
/>
<h2 className="settings-general__section-title">{t("common_redist")}</h2>
<p className="settings-general__common-redist-description">
{t("common_redist_description")}
</p>
<Button
onClick={handleInstallCommonRedist}
className="settings-general__common-redist-button"
disabled={!canInstallCommonRedist || installingCommonRedist}
>
<DesktopDownloadIcon />
{installingCommonRedist
? t("installing_common_redist")
: t("install_common_redist")}
</Button>
</div>
);
}

View File

@@ -57,3 +57,5 @@ export enum DownloadError {
GofileQuotaExceeded = "download_error_gofile_quota_exceeded",
RealDebridAccountNotAuthorized = "download_error_real_debrid_account_not_authorized",
}
export const FILE_EXTENSIONS_TO_EXTRACT = [".rar", ".zip", ".7z"];

View File

@@ -1,5 +1,21 @@
import {
ptBR,
enUS,
es,
fr,
pl,
hu,
tr,
ru,
it,
be,
zhCN,
da,
} from "date-fns/locale";
import { charMap } from "./char-map";
import { Downloader } from "./constants";
import { format } from "date-fns";
export * from "./constants";
@@ -124,3 +140,29 @@ export const steamUrlBuilder = {
icon: (objectId: string, clientIcon: string) =>
`https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectId}/${clientIcon}.ico`,
};
export const getDateLocale = (language: string) => {
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;
};
export const formatDate = (
date: number | Date | string,
language: string
): string => {
if (isNaN(new Date(date).getDate())) return "N/A";
const locale = getDateLocale(language);
return format(date, locale == enUS ? "MM/dd/yyyy" : "dd/MM/yyyy");
};

View File

@@ -7,7 +7,8 @@ export type DownloadStatus =
| "error"
| "complete"
| "seeding"
| "removed";
| "removed"
| "extracting";
export interface DownloadProgress {
downloadSpeed: number;

View File

@@ -22,6 +22,20 @@ export interface GameRepack {
updatedAt: Date;
}
export interface DownloadSource {
id: number;
name: string;
url: string;
repackCount: number;
status: DownloadSourceStatus;
objectIds: string[];
downloadCount: number;
fingerprint: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
}
export type ShopDetails = SteamAppDetails & {
objectId: string;
};
@@ -77,6 +91,7 @@ export interface StartGameDownloadPayload {
uri: string;
downloadPath: string;
downloader: Downloader;
automaticallyExtract: boolean;
}
export interface UserFriend {
@@ -197,20 +212,6 @@ export interface DownloadSourceValidationResult {
downloadCount: number;
}
export interface DownloadSource {
id: number;
name: string;
url: string;
repackCount: number;
status: DownloadSourceStatus;
objectIds: string[];
downloadCount: number;
fingerprint: string;
etag: string | null;
createdAt: Date;
updatedAt: Date;
}
export interface GameStats {
downloadCount: number;
playerCount: number;

View File

@@ -60,6 +60,8 @@ export interface Download {
status: DownloadStatus | null;
queued: boolean;
timestamp: number;
extracting: boolean;
automaticallyExtract: boolean;
}
export interface GameAchievement {