first commit

This commit is contained in:
Hydra 2024-04-18 08:46:06 +01:00
commit 91b1341271
No known key found for this signature in database
165 changed files with 20993 additions and 0 deletions

13
src/declaration.d.ts vendored Normal file
View file

@ -0,0 +1,13 @@
declare module "tasklist" {
interface Task {
imageName: string;
pid: number;
sessionName: string;
sessionNumber: number;
memUsage: number;
}
function tasklist(): Promise<Task[]>;
export { tasklist };
}

11
src/index.html Normal file
View file

@ -0,0 +1,11 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Hydra</title>
</head>
<body>
<div id="root"></div>
</body>
</html>

100
src/index.ts Normal file
View file

@ -0,0 +1,100 @@
import { app, BrowserWindow } from "electron";
import { init } from "@sentry/electron/main";
import i18n from "i18next";
import path from "node:path";
import { resolveDatabaseUpdates, WindowManager } from "@main/services";
import { updateElectronApp } from "update-electron-app";
import { dataSource } from "@main/data-source";
import * as resources from "@locales";
import { userPreferencesRepository } from "@main/repository";
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) app.quit();
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (require("electron-squirrel-startup")) app.quit();
if (process.platform !== "darwin") {
updateElectronApp();
}
if (process.env.SENTRY_DSN) {
init({ dsn: process.env.SENTRY_DSN });
}
i18n.init({
resources,
lng: "en",
fallbackLng: "en",
interpolation: {
escapeValue: false,
},
});
const PROTOCOL = "hydralauncher";
if (process.defaultApp) {
if (process.argv.length >= 2) {
app.setAsDefaultProtocolClient(PROTOCOL, process.execPath, [
path.resolve(process.argv[1]),
]);
}
} else {
app.setAsDefaultProtocolClient(PROTOCOL);
}
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on("ready", () => {
dataSource.initialize().then(async () => {
await resolveDatabaseUpdates();
await import("./main");
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
WindowManager.createMainWindow();
WindowManager.createSystemTray(userPreferences?.language || "en");
});
});
app.on("second-instance", (_event, commandLine) => {
// Someone tried to run a second instance, we should focus our window.
if (WindowManager.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
WindowManager.mainWindow.focus();
} else {
WindowManager.createMainWindow();
}
const [, path] = commandLine.pop().split("://");
if (path) WindowManager.redirect(path);
});
app.on("open-url", (_event, url) => {
const [, path] = url.split("://");
WindowManager.redirect(path);
});
// Quit when all windows are closed, except on macOS. There, it's common
// for applications and their menu bar to stay active until the user quits
// explicitly with Cmd + Q.
app.on("window-all-closed", () => {
WindowManager.mainWindow = null;
});
app.on("activate", () => {
// On OS X it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (BrowserWindow.getAllWindows().length === 0) {
WindowManager.createMainWindow();
}
});
// In this file you can include the rest of your app's specific main process
// code. You can also put them in separate files and import them here.

View file

@ -0,0 +1,131 @@
{
"catalogue": {
"featured": "Featured",
"recently_added": "Recently added",
"trending": "Trending",
"surprise_me": "Surprise me",
"no_results": "No results found"
},
"sidebar": {
"catalogue": "Catalogue",
"downloads": "Downloads",
"settings": "Settings",
"my_library": "My library",
"downloading_metadata": "{{title}} (Downloading metadata…)",
"checking_files": "{{title}} ({{percentage}} - Checking files…)",
"paused": "{{title}} (Paused)",
"downloading": "{{title}} ({{percentage}} - Downloading…)",
"filter": "Filter library"
},
"header": {
"search": "Search",
"catalogue": "Catalogue",
"downloads": "Downloads",
"search_results": "Search results",
"settings": "Settings"
},
"bottom_panel": {
"no_downloads_in_progress": "No downloads in progress",
"downloading_metadata": "Downloading {{title}} metadata…",
"checking_files": "Checking {{title}} files… ({{percentage}} complete)",
"downloading": "Downloading {{title}}… ({{percentage}} complete) - Conclusion {{eta}} - {{speed}}"
},
"game_details": {
"open_download_options": "Open download options",
"download_options_zero": "No download option",
"download_options_one": "{{count}} download option",
"download_options_other": "{{count}} download options",
"updated_at": "Updated {{updated_at}}",
"install": "Install",
"resume": "Resume",
"pause": "Pause",
"cancel": "Cancel",
"remove": "Remove",
"remove_from_list": "Remove",
"space_left_on_disk": "{{space}} left on disk",
"eta": "Conclusion {{eta}}",
"downloading_metadata": "Downloading metadata…",
"checking_files": "Checking files…",
"filter": "Filter repacks",
"requirements": "System requirements",
"minimum": "Minimum",
"recommended": "Recommended",
"no_minimum_requirements": "{{title}} doesn't provide minimum requirements information",
"no_recommended_requirements": "{{title}} doesn't provide recommended requirements information",
"paused_progress": "{{progress}} (Paused)",
"release_date": "Released in {{date}}",
"publisher": "Published by {{publisher}}",
"copy_link_to_clipboard": "Copy link",
"copied_link_to_clipboard": "Link copied",
"hours": "hours",
"minutes": "minutes",
"accuracy": "{{accuracy}}% accuracy",
"add_to_library": "Add to library",
"remove_from_library": "Remove from library",
"no_downloads": "No downloads available",
"play_time": "Played for {{amount}}",
"last_time_played": "Played for the last time {{period}}",
"not_played_yet": "You haven't played {{title}} yet",
"next_suggestion": "Next suggestion",
"play": "Play",
"deleting": "Deleting installer…",
"close": "Close"
},
"activation": {
"title": "Activate Hydra",
"installation_id": "Installation ID:",
"enter_activation_code": "Enter your activation code",
"message": "If you don't know where to ask for this, then you shouldn't have this.",
"activate": "Activate",
"loading": "Loading…"
},
"downloads": {
"resume": "Resume",
"pause": "Pause",
"eta": "Conclusion {{eta}}",
"paused": "Paused",
"verifying": "Verifying…",
"completed_at": "Completed in {{date}}",
"completed": "Completed",
"cancelled": "Cancelled",
"download_again": "Download again",
"cancel": "Cancel",
"filter": "Filter downloaded games",
"remove": "Remove",
"downloading_metadata": "Downloading metadata…",
"checking_files": "Checking files…",
"starting_download": "Starting download…",
"deleting": "Deleting installer…",
"delete": "Remove installer",
"remove_from_list": "Remove",
"delete_modal_title": "Are you sure?",
"delete_modal_description": "This will remove all the installation files from your computer",
"install": "Install"
},
"settings": {
"downloads_path": "Downloads path",
"change": "Update",
"notifications": "Notifications",
"enable_download_notifications": "When a download is complete",
"enable_repack_list_notifications": "When a new repack is added"
},
"notifications": {
"download_complete": "Download complete",
"game_ready_to_install": "{{title}} is ready to install",
"repack_list_updated": "Repack list updated",
"repack_count_one": "{{count}} repack added",
"repack_count_other": "{{count}} repacks added"
},
"system_tray": {
"open": "Open Hydra",
"quit": "Quit"
},
"game_card": {
"no_downloads": "No downloads available"
},
"binary_not_found_modal": {
"title": "Programs not installed",
"description": "Wine or Lutris executables were not found on your system",
"instructions": "Check the correct way to install any of them on your Linux distro so that the game can run normally"
}
}

View file

@ -0,0 +1,131 @@
{
"catalogue": {
"featured": "Destacado",
"recently_added": "Recién Añadidos",
"trending": "Tendencias",
"surprise_me": "¡Sorpréndeme!",
"no_results": "No se encontraron resultados"
},
"sidebar": {
"catalogue": "Catálogo",
"downloads": "Descargas",
"settings": "Ajustes",
"my_library": "Mi biblioteca",
"downloading_metadata": "{{title}} (Descargando metadatos…)",
"checking_files": "{{title}} ({{percentage}} - Analizando archivos…)",
"paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Descargando…)",
"filter": "Filtrar biblioteca"
},
"header": {
"search": "Buscar",
"catalogue": "Catálogo",
"downloads": "Descargas",
"search_results": "Resultados de búsqueda",
"settings": "Ajustes"
},
"bottom_panel": {
"no_downloads_in_progress": "Sin descargas en progreso",
"downloading_metadata": "Descargando metadatos de {{title}}…",
"checking_files": "Analizando archivos de {{title}} - ({{percentage}} completado)",
"downloading": "Descargando {{title}}… ({{percentage}} completado) - Finalizando {{eta}} - {{speed}}"
},
"game_details": {
"open_download_options": "Ver opciones de descargas",
"download_options_zero": "No hay opciones de descargas disponibles",
"download_options_one": "{{count}} opción de descarga",
"download_options_other": "{{count}} opciones de descargas",
"updated_at": "Actualizado el {{updated_at}}",
"resume": "Continuar",
"pause": "Pausa",
"cancel": "Cancelar",
"remove": "Eliminar",
"remove_from_list": "Quitar",
"space_left_on_disk": "{{space}} restantes en el disco",
"eta": "Finalizando {{eta}}",
"downloading_metadata": "Descargando metadatos…",
"checking_files": "Analizando archivos…",
"filter": "Filtrar repacks",
"requirements": "Requisitos del Sistema",
"minimum": "Mínimos",
"recommended": "Recomendados",
"no_minimum_requirements": "Sin requisitos mínimos para {{title}}",
"no_recommended_requirements": "{{title}} no tiene requisitos recomendados",
"paused_progress": "{{progress}} (Pausado)",
"release_date": "Fecha de lanzamiento {{date}}",
"publisher": "Publicado por {{publisher}}",
"copy_link_to_clipboard": "Copiar enlace",
"copied_link_to_clipboard": "Enlace copiado",
"hours": "horas",
"minutes": "minutos",
"accuracy": "{{accuracy}}% precisión",
"add_to_library": "Agregar a la biblioteca",
"remove_from_library": "Eliminar de la biblioteca",
"no_downloads": "No hay descargas disponibles",
"next_suggestion": "Siguiente sugerencia",
"play_time": "Jugado por {{cantidad}}",
"install": "Instalar",
"last_time_played": "Jugado por última vez {{period}}",
"play": "Jugar",
"not_played_yet": "Aún no has jugado a {{title}}",
"close": "Cerca",
"deleting": "Eliminando instalador…"
},
"activation": {
"title": "Activar Hydra",
"installation_id": "ID de la Instalación:",
"enter_activation_code": "Introduce tu código de activación",
"message": "Si no sabes donde obtener el código, no deberías de tener esto.",
"activate": "Activar",
"loading": "Cargando…"
},
"downloads": {
"resume": "Resumir",
"pause": "Pausa",
"eta": "Finalizando {{eta}}",
"paused": "En Pausa",
"verifying": "Verificando…",
"completed_at": "Completado el {{date}}",
"completed": "Completado",
"cancelled": "Cancelado",
"download_again": "Descargar de nuevo",
"cancel": "Cancelar",
"filter": "Buscar juegos descargados",
"remove": "Eliminar",
"downloading_metadata": "Descargando metadatos…",
"checking_files": "Verificando archivos…",
"starting_download": "Iniciando descarga…",
"remove_from_list": "Eliminar",
"delete": "Quitar instalador",
"delete_modal_description": "Esto eliminará todos los archivos de instalación de su computadora.",
"delete_modal_title": "¿Está seguro?",
"deleting": "Eliminando instalador…",
"install": "Instalar"
},
"settings": {
"downloads_path": "Ruta de descarga",
"change": "Cambiar",
"notifications": "Notificaciones",
"enable_download_notifications": "Cuando se completa una descarga",
"enable_repack_list_notifications": "Cuando se añade un repack nuevo"
},
"notifications": {
"download_complete": "Descarga completada",
"game_ready_to_install": "{{title}} está listo para instalarse",
"repack_list_updated": "Lista de repacks actualizadas",
"repack_count_one": "{{count}} repack ha sido añadido",
"repack_count_other": "{{count}} repacks añadidos"
},
"system_tray": {
"open": "Abrir Hydra",
"quit": "Salir"
},
"game_card": {
"no_downloads": "No hay descargas disponibles"
},
"binary_not_found_modal": {
"title": "Programas no instalados",
"description": "Los ejecutables de Wine o Lutris no se encontraron en su sistema",
"instructions": "Comprueba la forma correcta de instalar cualquiera de ellos en tu distro Linux para que el juego pueda ejecutarse con normalidad"
}
}

View file

@ -0,0 +1,131 @@
{
"catalogue": {
"featured": "En vedette",
"recently_added": "Récemment ajouté",
"trending": "Tendance",
"surprise_me": "Surprenez-moi",
"no_results": "Aucun résultat trouvé"
},
"sidebar": {
"catalogue": "Catalogue",
"downloads": "Téléchargements",
"settings": "Paramètres",
"my_library": "Ma bibliothèque",
"downloading_metadata": "{{title}} (Téléchargement des métadonnées…)",
"checking_files": "{{title}} ({{percentage}} - Vérification des fichiers…)",
"paused": "{{title}} (En pause)",
"downloading": "{{title}} ({{percentage}} - Téléchargement en cours…)",
"filter": "Filtrer la bibliothèque"
},
"header": {
"search": "Recherche",
"catalogue": "Catalogue",
"downloads": "Téléchargements",
"search_results": "Résultats de la recherche",
"settings": "Paramètres"
},
"bottom_panel": {
"no_downloads_in_progress": "Aucun téléchargement en cours",
"downloading_metadata": "Téléchargement des métadonnées de {{title}}…",
"checking_files": "Vérification des fichiers de {{title}}… ({{percentage}} complet)",
"downloading": "Téléchargement de {{title}}… ({{percentage}} complet) - Conclusion dans {{eta}} - {{speed}}"
},
"game_details": {
"open_download_options": "Ouvrir les options de téléchargement",
"download_options_zero": "Aucune option de téléchargement",
"download_options_one": "{{count}} option de téléchargement",
"download_options_other": "{{count}} options de téléchargement",
"updated_at": "Mis à jour le {{updated_at}}",
"resume": "Reprendre",
"pause": "Pause",
"cancel": "Annuler",
"remove": "Supprimer",
"remove_from_list": "Retirer",
"space_left_on_disk": "{{space}} restant sur le disque",
"eta": "Conclusion dans {{eta}}",
"downloading_metadata": "Téléchargement des métadonnées en cours…",
"checking_files": "Vérification des fichiers…",
"filter": "Filtrer les réductions",
"requirements": "Configuration requise",
"minimum": "Minimum",
"recommended": "Recommandée",
"no_minimum_requirements": "{{title}} ne fournit pas d'informations sur les exigences minimales",
"no_recommended_requirements": "{{title}} ne fournit pas d'informations sur les exigences recommandées",
"paused_progress": "{{progress}} (En pause)",
"release_date": "Sorti le {{date}}",
"publisher": "Édité par {{publisher}}",
"copy_link_to_clipboard": "Copier le lien",
"copied_link_to_clipboard": "Lien copié",
"hours": "heures",
"minutes": "minutes",
"accuracy": "{{accuracy}}% précision",
"add_to_library": "Ajouter à la bibliothèque",
"remove_from_library": "Supprimer de la bibliothèque",
"no_downloads": "Aucun téléchargement disponible",
"next_suggestion": "Suggestion suivante",
"play_time": "Joué pour {{montant}}",
"install": "Installer",
"last_time_played": "Joué pour la dernière fois {{période}}",
"play": "Jouer",
"not_played_yet": "Vous n'avez pas encore joué à {{title}}",
"close": "Fermer",
"deleting": "Suppression du programme d'installation…"
},
"activation": {
"title": "Activer Hydra",
"installation_id": "ID d'installation :",
"enter_activation_code": "Entrez votre code d'activation",
"message": "Si vous ne savez pas où demander cela, vous ne devriez pas l'avoir.",
"activate": "Activer",
"loading": "Chargement en cours…"
},
"downloads": {
"resume": "Reprendre",
"pause": "Pause",
"eta": "Conclusion dans {{eta}}",
"paused": "En pause",
"verifying": "Vérification en cours…",
"completed_at": "Terminé en {{date}}",
"completed": "Terminé",
"cancelled": "Annulé",
"download_again": "Télécharger à nouveau",
"cancel": "Annuler",
"filter": "Filtrer les jeux téléchargés",
"remove": "Supprimer",
"downloading_metadata": "Téléchargement des métadonnées en cours…",
"checking_files": "Vérification des fichiers…",
"starting_download": "Démarrage du téléchargement…",
"remove_from_list": "Retirer",
"delete": "Supprimer le programme d'installation",
"delete_modal_description": "Cela supprimera tous les fichiers d'installation de votre ordinateur",
"delete_modal_title": "Es-tu sûr?",
"deleting": "Suppression du programme d'installation…",
"install": "Installer"
},
"settings": {
"downloads_path": "Chemin des téléchargements",
"change": "Mettre à jour",
"notifications": "Notifications",
"enable_download_notifications": "Quand un téléchargement est terminé",
"enable_repack_list_notifications": "Quand une nouvelle réduction est ajoutée"
},
"notifications": {
"download_complete": "Téléchargement terminé",
"game_ready_to_install": "{{title}} est prêt à être installé",
"repack_list_updated": "Liste de réductions mise à jour",
"repack_count_one": "{{count}} réduction ajoutée",
"repack_count_other": "{{count}} réductions ajoutées"
},
"system_tray": {
"open": "Ouvrir Hydra",
"quit": "Quitter"
},
"game_card": {
"no_downloads": "Aucun téléchargement disponible"
},
"binary_not_found_modal": {
"description": "Les exécutables Wine ou Lutris sont introuvables sur votre système",
"instructions": "Vérifiez la bonne façon d'installer l'un d'entre eux sur votre distribution Linux afin que le jeu puisse fonctionner normalement",
"title": "Programmes non installés"
}
}

4
src/locales/index.ts Normal file
View file

@ -0,0 +1,4 @@
export { default as en } from "./en/translation.json";
export { default as pt } from "./pt/translation.json";
export { default as es } from "./es/translation.json";
export { default as fr } from "./fr/translation.json";

View file

@ -0,0 +1,131 @@
{
"catalogue": {
"featured": "Destaque",
"recently_added": "Novidades",
"trending": "Populares",
"surprise_me": "Me surpreenda",
"no_results": "Nenhum resultado encontrado"
},
"sidebar": {
"catalogue": "Catálogo",
"downloads": "Downloads",
"settings": "Configurações",
"my_library": "Minha biblioteca",
"downloading_metadata": "{{title}} (Baixando metadados…)",
"checking_files": "{{title}} ({{percentage}} - Verificando arquivos…)",
"paused": "{{title}} (Pausado)",
"downloading": "{{title}} ({{percentage}} - Baixando…)",
"filter": "Filtrar biblioteca"
},
"header": {
"search": "Buscar",
"catalogue": "Catálogo",
"downloads": "Downloads",
"search_results": "Resultados da busca",
"settings": "Configurações"
},
"bottom_panel": {
"no_downloads_in_progress": "Sem downloads em andamento",
"downloading_metadata": "Baixando metadados de {{title}}…",
"checking_files": "Verificando arquivos de {{title}}… ({{percentage}} completo)",
"downloading": "Baixando {{title}}… ({{percentage}} completo) - Conclusão {{eta}} - {{speed}}"
},
"game_details": {
"open_download_options": "Ver opções de download",
"download_options_zero": "Sem opções de download",
"download_options_one": "{{count}} opção de download",
"download_options_other": "{{count}} opções de download",
"updated_at": "Atualizado {{updated_at}}",
"resume": "Resumir",
"pause": "Pausar",
"cancel": "Cancelar",
"remove": "Remover",
"remove_from_list": "Remover",
"space_left_on_disk": "{{space}} livres em disco",
"eta": "Conclusão {{eta}}",
"downloading_metadata": "Baixando metadados…",
"checking_files": "Verificando arquivos…",
"filter": "Filtrar repacks",
"requirements": "Requisitos do sistema",
"minimum": "Mínimos",
"recommended": "Recomendados",
"no_minimum_requirements": "{{title}} não possui informações de requisitos mínimos",
"no_recommended_requirements": "{{title}} não possui informações de requisitos recomendados",
"paused_progress": "{{progress}} (Pausado)",
"release_date": "Lançado em {{date}}",
"publisher": "Publicado por {{publisher}}",
"copy_link_to_clipboard": "Copiar link",
"copied_link_to_clipboard": "Link copiado",
"hours": "horas",
"minutes": "minutos",
"accuracy": "{{accuracy}}% de precisão",
"add_to_library": "Adicionar à biblioteca",
"remove_from_library": "Remover da biblioteca",
"no_downloads": "Nenhum download disponível",
"play_time": "Jogado por {{amount}}",
"next_suggestion": "Próxima sugestão",
"install": "Instalar",
"last_time_played": "Jogado pela última vez {{period}}",
"play": "Jogar",
"not_played_yet": "Você ainda não jogou {{title}}",
"close": "Fechar",
"deleting": "Excluindo instalador…"
},
"activation": {
"title": "Ativação",
"installation_id": "ID da instalação:",
"enter_activation_code": "Insira seu código de ativação",
"message": "Se você não sabe onde conseguir o código, talvez você não devesse estar aqui.",
"activate": "Ativar",
"loading": "Carregando…"
},
"downloads": {
"resume": "Resumir",
"pause": "Pausar",
"eta": "Conclusão {{eta}}",
"paused": "Pausado",
"verifying": "Verificando…",
"completed_at": "Concluído em {{date}}",
"completed": "Concluído",
"cancelled": "Cancelado",
"download_again": "Baixar novamente",
"cancel": "Cancelar",
"filter": "Filtrar jogos baixados",
"remove": "Remover",
"downloading_metadata": "Baixando metadados…",
"checking_files": "Verificando arquivos…",
"starting_download": "Iniciando download…",
"remove_from_list": "Remover",
"delete": "Remover instalador",
"delete_modal_description": "Isso removerá todos os arquivos de instalação do seu computador",
"delete_modal_title": "Tem certeza?",
"deleting": "Excluindo instalador…",
"install": "Instalar"
},
"settings": {
"downloads_path": "Diretório dos downloads",
"change": "Mudar",
"notifications": "Notificações",
"enable_download_notifications": "Quando um download for concluído",
"enable_repack_list_notifications": "Quando a lista de repacks for atualizada"
},
"notifications": {
"download_complete": "Download concluído",
"game_ready_to_install": "{{title}} está pronto para ser instalado",
"repack_list_updated": "Lista de repacks atualizada",
"repack_count_one": "{{count}} novo repack",
"repack_count_other": "{{count}} novos repacks"
},
"system_tray": {
"open": "Abrir Hydra",
"quit": "Fechar"
},
"game_card": {
"no_downloads": "Sem downloads disponíveis"
},
"binary_not_found_modal": {
"title": "Programas não instalados",
"description": "Não foram encontrados no seu sistema os executáveis do Wine ou Lutris",
"instructions": "Verifique a forma correta de instalar algum deles na sua distro Linux para que o jogo possa ser executado normalmente"
}
}

54
src/main/constants.ts Normal file
View file

@ -0,0 +1,54 @@
import { app } from "electron";
import os from "node:os";
import path from "node:path";
export const repackersOn1337x = [
"DODI",
"FitGirl",
"0xEMPRESS",
"KaOsKrew",
"TinyRepacks",
] as const;
export const repackers = [
...repackersOn1337x,
"Xatab",
"CPG",
"TinyRepacks",
"GOG",
] as const;
export const months = [
"Jan",
"Feb",
"Mar",
"Apr",
"May",
"Jun",
"Jul",
"Aug",
"Sep",
"Oct",
"Nov",
"Dec",
];
export enum GameStatus {
Seeding = "seeding",
Downloading = "downloading",
Paused = "paused",
CheckingFiles = "checking_files",
DownloadingMetadata = "downloading_metadata",
Cancelled = "cancelled",
}
export const defaultDownloadsPath = path.join(os.homedir(), "downloads");
export const databasePath = path.join(
app.getPath("appData"),
app.getName(),
"hydra.db"
);
export const INSTALLATION_ID_LENGTH = 6;
export const ACTIVATION_KEY_MULTIPLIER = 7;

33
src/main/data-source.ts Normal file
View file

@ -0,0 +1,33 @@
import { DataSource } from "typeorm";
import {
Game,
GameShopCache,
ImageCache,
Repack,
RepackerFriendlyName,
UserPreferences,
MigrationScript,
} from "@main/entity";
import type { SqliteConnectionOptions } from "typeorm/driver/sqlite/SqliteConnectionOptions";
import { databasePath } from "./constants";
export const createDataSource = (options: Partial<SqliteConnectionOptions>) =>
new DataSource({
type: "sqlite",
database: databasePath,
entities: [
Game,
ImageCache,
Repack,
RepackerFriendlyName,
UserPreferences,
GameShopCache,
MigrationScript,
],
...options,
});
export const dataSource = createDataSource({
synchronize: true,
});

View file

@ -0,0 +1,32 @@
import {
Entity,
PrimaryColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
import type { GameShop } from "@types";
@Entity("game_shop_cache")
export class GameShopCache {
@PrimaryColumn("text", { unique: true })
objectID: string;
@Column("text")
shop: GameShop;
@Column("text", { nullable: true })
serializedData: string;
@Column("text", { nullable: true })
howLongToBeatSerializedData: string;
@Column("text", { nullable: true })
language: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -0,0 +1,69 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToOne,
JoinColumn,
} from "typeorm";
import type { GameShop } from "@types";
import { Repack } from "./repack.entity";
@Entity("game")
export class Game {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
objectID: string;
@Column("text")
title: string;
@Column("text")
iconUrl: string;
@Column("text", { nullable: true })
folderName: string | null;
@Column("text", { nullable: true })
downloadPath: string | null;
@Column("text", { nullable: true })
executablePath: string | null;
@Column("int", { default: 0 })
playTimeInMilliseconds: number;
@Column("text")
shop: GameShop;
@Column("text", { nullable: true })
status: string;
@Column("float", { default: 0 })
progress: number;
@Column("float", { default: 0 })
fileVerificationProgress: number;
@Column("int", { default: 0 })
bytesDownloaded: number;
@Column("text", { nullable: true })
lastTimePlayed: Date | null;
@Column("float", { default: 0 })
fileSize: number;
@OneToOne(() => Repack, { nullable: true })
@JoinColumn()
repack: Repack;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("image_cache")
export class ImageCache {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
url: string;
@Column("text")
data: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

7
src/main/entity/index.ts Normal file
View file

@ -0,0 +1,7 @@
export * from "./game.entity";
export * from "./image-cache.entity";
export * from "./repack.entity";
export * from "./repacker-friendly-name.entity";
export * from "./user-preferences.entity";
export * from "./game-shop-cache.entity";
export * from "./migration-script.entity";

View file

@ -0,0 +1,22 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("migration_script")
export class MigrationScript {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
version: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -0,0 +1,37 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("repack")
export class Repack {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
title: string;
@Column("text", { unique: true })
magnet: string;
@Column("int")
page: number;
@Column("text")
repacker: string;
@Column("text")
fileSize: string;
@Column("datetime")
uploadDate: Date | string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -0,0 +1,25 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("repacker_friendly_name")
export class RepackerFriendlyName {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { unique: true })
name: string;
@Column("text")
friendlyName: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -0,0 +1,31 @@
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from "typeorm";
@Entity("user_preferences")
export class UserPreferences {
@PrimaryGeneratedColumn()
id: number;
@Column("text", { nullable: true })
downloadsPath: string | null;
@Column("text", { default: "en" })
language: string;
@Column("boolean", { default: false })
downloadNotificationsEnabled: boolean;
@Column("boolean", { default: false })
repackUpdatesNotificationsEnabled: boolean;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
}

View file

@ -0,0 +1,76 @@
import { formatName, repackerFormatter } from "@main/helpers";
import { getTrendingGames } from "@main/services";
import type { CatalogueCategory, CatalogueEntry } from "@types";
import { stateManager } from "@main/state-manager";
import { searchGames } from "../helpers/search-games";
import { registerEvent } from "../register-event";
const repacks = stateManager.getValue("repacks");
const getCatalogue = async (
_event: Electron.IpcMainInvokeEvent,
category: CatalogueCategory
) => {
const trendingGames = await getTrendingGames();
let i = 0;
const results: CatalogueEntry[] = [];
const getStringForLookup = (index: number) => {
if (category === "trending") return trendingGames[index];
const repack = repacks[index];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
return formatName(formatter(repack.title));
};
if (!repacks.length) return [];
const resultSize = 12;
const requestSize = resultSize * 2;
let lookupRequest = [];
while (results.length < resultSize) {
const stringForLookup = getStringForLookup(i);
if (!stringForLookup) {
i++;
continue;
}
lookupRequest.push(searchGames(stringForLookup));
i++;
if (lookupRequest.length < requestSize) {
continue;
}
const games = (await Promise.all(lookupRequest)).map((value) =>
value.at(0)
);
for (const game of games) {
const isAlreadyIncluded = results.some(
(result) => result.objectID === game?.objectID
);
if (!game || !game.repacks.length || isAlreadyIncluded) {
continue;
}
results.push(game);
}
lookupRequest = [];
}
return results.slice(0, resultSize);
};
registerEvent(getCatalogue, {
name: "getCatalogue",
memoize: true,
});

View file

@ -0,0 +1,72 @@
import { gameShopCacheRepository } from "@main/repository";
import { getSteamAppDetails } from "@main/services";
import type { ShopDetails, GameShop, SteamAppDetails } from "@types";
import { registerEvent } from "../register-event";
import { searchRepacks } from "../helpers/search-games";
const getGameShopDetails = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
shop: GameShop,
language: string
): Promise<ShopDetails | null> => {
if (shop === "steam") {
const cachedData = await gameShopCacheRepository.findOne({
where: { objectID, language },
});
const result = Promise.all([
getSteamAppDetails(objectID, "english"),
getSteamAppDetails(objectID, language),
]).then(([appDetails, localizedAppDetails]) => {
if (appDetails && localizedAppDetails) {
gameShopCacheRepository.upsert(
{
objectID,
shop: "steam",
language,
serializedData: JSON.stringify({
...localizedAppDetails,
name: appDetails.name,
}),
},
["objectID"]
);
}
return [appDetails, localizedAppDetails];
});
const cachedGame = cachedData?.serializedData
? (JSON.parse(cachedData?.serializedData) as SteamAppDetails)
: null;
if (cachedGame) {
return {
...cachedGame,
repacks: searchRepacks(cachedGame.name),
objectID,
} as ShopDetails;
}
return result.then(([appDetails, localizedAppDetails]) => {
if (!appDetails || !localizedAppDetails) return null;
return {
...localizedAppDetails,
name: appDetails.name,
repacks: searchRepacks(appDetails.name),
objectID,
} as ShopDetails;
});
}
throw new Error("Not implemented");
};
registerEvent(getGameShopDetails, {
name: "getGameShopDetails",
memoize: true,
});

View file

@ -0,0 +1,48 @@
import type { GameShop, HowLongToBeatCategory } from "@types";
import { getHowLongToBeatGame, searchHowLongToBeat } from "@main/services";
import { registerEvent } from "../register-event";
import { gameShopCacheRepository } from "@main/repository";
const getHowLongToBeat = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
shop: GameShop,
title: string
): Promise<HowLongToBeatCategory[] | null> => {
const searchHowLongToBeatPromise = searchHowLongToBeat(title);
const gameShopCache = await gameShopCacheRepository.findOne({
where: { objectID, shop },
});
const howLongToBeatCachedData = gameShopCache?.howLongToBeatSerializedData
? JSON.parse(gameShopCache?.howLongToBeatSerializedData)
: null;
if (howLongToBeatCachedData) return howLongToBeatCachedData;
return searchHowLongToBeatPromise.then(async (response) => {
const game = response.data.find(
(game) => game.profile_steam === Number(objectID)
);
if (!game) return null;
const howLongToBeat = await getHowLongToBeatGame(String(game.game_id));
gameShopCacheRepository.upsert(
{
objectID,
shop,
howLongToBeatSerializedData: JSON.stringify(howLongToBeat),
},
["objectID"]
);
return howLongToBeat;
});
};
registerEvent(getHowLongToBeat, {
name: "getHowLongToBeat",
memoize: true,
});

View file

@ -0,0 +1,29 @@
import shuffle from "lodash/shuffle";
import { getRandomSteam250List } from "@main/services";
import { registerEvent } from "../register-event";
import { searchGames, searchRepacks } from "../helpers/search-games";
import { formatName } from "@main/helpers";
const getRandomGame = async (_event: Electron.IpcMainInvokeEvent) => {
return getRandomSteam250List().then(async (games) => {
const shuffledList = shuffle(games);
for (const game of shuffledList) {
const repacks = searchRepacks(formatName(game));
if (repacks.length) {
const results = await searchGames(game);
if (results.length) {
return results[0].objectID;
}
}
}
});
};
registerEvent(getRandomGame, {
name: "getRandomGame",
});

View file

@ -0,0 +1,10 @@
import { registerEvent } from "../register-event";
import { searchGames } from "../helpers/search-games";
registerEvent(
(_event: Electron.IpcMainInvokeEvent, query: string) => searchGames(query),
{
name: "searchGames",
memoize: true,
}
);

View file

@ -0,0 +1,11 @@
import checkDiskSpace from "check-disk-space";
import { registerEvent } from "../register-event";
import { getDownloadsPath } from "../helpers/get-downloads-path";
const getDiskFreeSpace = async (_event: Electron.IpcMainInvokeEvent) =>
checkDiskSpace(await getDownloadsPath());
registerEvent(getDiskFreeSpace, {
name: "getDiskFreeSpace",
});

View file

@ -0,0 +1,44 @@
import { Document as YMLDocument } from "yaml";
import { Game } from "@main/entity";
import path from "node:path";
export const generateYML = (game: Game) => {
const slugifiedGameTitle = game.title.replace(/\s/g, "-").toLocaleLowerCase();
const doc = new YMLDocument({
name: game.title,
game_slug: slugifiedGameTitle,
slug: `${slugifiedGameTitle}-installer`,
version: "Installer",
runner: "wine",
script: {
game: {
prefix: "$GAMEDIR",
arch: "win64",
working_dir: "$GAMEDIR",
},
installer: [
{
task: {
name: "create_prefix",
arch: "win64",
prefix: "$GAMEDIR",
},
},
{
task: {
executable: path.join(
game.downloadPath,
game.folderName,
"setup.exe"
),
name: "wineexec",
prefix: "$GAMEDIR",
},
},
],
},
});
return doc.toString();
};

View file

@ -0,0 +1,15 @@
import { userPreferencesRepository } from "@main/repository";
import { defaultDownloadsPath } from "@main/constants";
export const getDownloadsPath = async () => {
const userPreferences = await userPreferencesRepository.findOne({
where: {
id: 1,
},
});
if (userPreferences && userPreferences.downloadsPath)
return userPreferences.downloadsPath;
return defaultDownloadsPath;
};

View file

@ -0,0 +1,72 @@
import flexSearch from "flexsearch";
import orderBy from "lodash/orderBy";
import type { GameRepack, GameShop, CatalogueEntry } from "@types";
import { formatName, getSteamAppAsset, repackerFormatter } from "@main/helpers";
import { searchSteamGame } from "@main/services";
import { stateManager } from "@main/state-manager";
const { Index } = flexSearch;
const repacksIndex = new Index();
const repacks = stateManager.getValue("repacks");
for (let i = 0; i < repacks.length; i++) {
const repack = repacks[i];
const formatter =
repackerFormatter[repack.repacker as keyof typeof repackerFormatter];
repacksIndex.add(i, formatName(formatter(repack.title)));
}
export const searchRepacks = (title: string): GameRepack[] => {
const repacks = stateManager.getValue("repacks");
return orderBy(
repacksIndex
.search(formatName(title))
.map((index) => repacks.at(index as number)!),
["uploadDate"],
"desc"
);
};
export const searchGames = async (query: string): Promise<CatalogueEntry[]> => {
const formattedName = formatName(query);
const steamResults = await searchSteamGame(formattedName);
const results = steamResults.map((result) => ({
objectID: result.objectID,
title: result.name,
shop: "steam" as GameShop,
cover: getSteamAppAsset("library", result.objectID),
}));
const gamesIndex = new Index({
tokenize: "full",
});
for (let i = 0; i < results.length; i++) {
const game = results[i];
gamesIndex.add(i, game.title);
}
const filteredResults = gamesIndex
.search(query)
.map((index) => results[index as number]);
return Promise.all(
filteredResults.map(async (result) => ({
...result,
repacks: searchRepacks(result.title),
}))
).then((resultsWithRepacks) =>
orderBy(
resultsWithRepacks,
[({ repacks }) => repacks.length, "repacks"],
["desc"]
)
);
};

30
src/main/events/index.ts Normal file
View file

@ -0,0 +1,30 @@
import { app, ipcMain } from "electron";
import { defaultDownloadsPath } from "@main/constants";
import "./library/add-game-to-library";
import "./catalogue/search-games";
import "./catalogue/get-game-shop-details";
import "./catalogue/get-catalogue";
import "./library/get-library";
import "./hardware/get-disk-free-space";
import "./torrenting/cancel-game-download";
import "./torrenting/pause-game-download";
import "./torrenting/resume-game-download";
import "./torrenting/start-game-download";
import "./misc/get-or-cache-image";
import "./user-preferences/update-user-preferences";
import "./user-preferences/get-user-preferences";
import "./library/get-repackers-friendly-names";
import "./library/get-game-by-object-id";
import "./library/open-game-installer";
import "./library/open-game";
import "./library/close-game";
import "./misc/show-open-dialog";
import "./library/remove-game";
import "./library/delete-game-folder";
import "./catalogue/get-random-game";
import "./catalogue/get-how-long-to-beat";
ipcMain.handle("ping", () => "pong");
ipcMain.handle("getVersion", () => app.getVersion());
ipcMain.handle("getDefaultDownloadsPath", () => defaultDownloadsPath);

View file

@ -0,0 +1,27 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getImageBase64 } from "@main/helpers";
import { getSteamGameIconUrl } from "@main/services";
const addGameToLibrary = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string,
title: string,
gameShop: GameShop,
) => {
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
return gameRepository.insert({
title,
iconUrl,
objectID,
shop: gameShop,
});
};
registerEvent(addGameToLibrary, {
name: "addGameToLibrary",
});

View file

@ -0,0 +1,35 @@
import path from "node:path";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { getProcesses } from "@main/helpers";
const closeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const processes = await getProcesses();
const game = await gameRepository.findOne({ where: { id: gameId } });
const gameProcess = processes.find((runningProcess) => {
const basename = path.win32.basename(game.executablePath);
const basenameWithoutExtension = path.win32.basename(
game.executablePath,
path.extname(game.executablePath)
);
if (process.platform === "win32") {
return runningProcess.name === basename;
}
return [basename, basenameWithoutExtension].includes(runningProcess.name);
});
if (gameProcess) return process.kill(gameProcess.pid);
return false;
};
registerEvent(closeGame, {
name: "closeGame",
});

View file

@ -0,0 +1,47 @@
import path from "node:path";
import fs from "node:fs";
import { GameStatus } from "@main/constants";
import { gameRepository } from "@main/repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { logger } from "@main/services";
import { registerEvent } from "../register-event";
const deleteGameFolder = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: GameStatus.Cancelled,
},
});
if (!game) return;
if (game.folderName) {
const folderPath = path.join(await getDownloadsPath(), game.folderName);
if (fs.existsSync(folderPath)) {
return new Promise((resolve, reject) => {
fs.rm(
folderPath,
{ recursive: true, force: true, maxRetries: 5, retryDelay: 200 },
(error) => {
if (error) {
logger.error(error);
reject();
}
resolve(null);
}
);
});
}
}
};
registerEvent(deleteGameFolder, {
name: "deleteGameFolder",
});

View file

@ -0,0 +1,20 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getGameByObjectID = async (
_event: Electron.IpcMainInvokeEvent,
objectID: string
) =>
gameRepository.findOne({
where: {
objectID,
},
relations: {
repack: true,
},
});
registerEvent(getGameByObjectID, {
name: "getGameByObjectID",
});

View file

@ -0,0 +1,30 @@
import { gameRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { searchRepacks } from "../helpers/search-games";
import { registerEvent } from "../register-event";
import sortBy from "lodash/sortBy";
const getLibrary = async (_event: Electron.IpcMainInvokeEvent) =>
gameRepository
.find({
order: {
createdAt: "desc",
},
relations: {
repack: true,
},
})
.then((games) =>
sortBy(
games.map((game) => ({
...game,
repacks: searchRepacks(game.title),
})),
(game) => (game.status !== GameStatus.Cancelled ? 0 : 1)
)
);
registerEvent(getLibrary, {
name: "getLibrary",
});

View file

@ -0,0 +1,12 @@
import { registerEvent } from "../register-event";
import { stateManager } from "@main/state-manager";
const getRepackersFriendlyNames = async (_event: Electron.IpcMainInvokeEvent) =>
stateManager.getValue("repackersFriendlyNames").reduce((prev, next) => {
return { ...prev, [next.name]: next.friendlyName };
}, {});
registerEvent(getRepackersFriendlyNames, {
name: "getRepackersFriendlyNames",
memoize: true,
});

View file

@ -0,0 +1,58 @@
import { gameRepository } from "@main/repository";
import { generateYML } from "../helpers/generate-lutris-yaml";
import path from "node:path";
import fs from "node:fs";
import { writeFile } from "node:fs/promises";
import { spawnSync, exec } from "node:child_process";
import { registerEvent } from "../register-event";
import { shell } from "electron";
import { getDownloadsPath } from "../helpers/get-downloads-path";
const openGameInstaller = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({ where: { id: gameId } });
if (!game) return true;
const gamePath = path.join(
game.downloadPath ?? (await getDownloadsPath()),
game.folderName
);
if (!fs.existsSync(gamePath)) {
await gameRepository.delete({ id: gameId });
return true;
}
const setupPath = path.join(gamePath, "setup.exe");
if (!fs.existsSync(setupPath)) {
shell.openPath(gamePath);
return true;
}
if (process.platform === "win32") {
shell.openPath(setupPath);
return true;
}
if (spawnSync("which", ["lutris"]).status === 0) {
const ymlPath = path.join(gamePath, "setup.yml");
await writeFile(ymlPath, generateYML(game));
exec(`lutris --install "${ymlPath}"`);
return true;
}
if (spawnSync("which", ["wine"]).status === 0) {
exec(`wine "${setupPath}"`);
return true;
}
return false;
};
registerEvent(openGameInstaller, {
name: "openGameInstaller",
});

View file

@ -0,0 +1,18 @@
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { shell } from "electron";
const openGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number,
executablePath: string
) => {
await gameRepository.update({ id: gameId }, { executablePath });
shell.openPath(executablePath);
};
registerEvent(openGame, {
name: "openGame",
});

View file

@ -0,0 +1,11 @@
import { registerEvent } from "../register-event";
import { gameRepository } from "../../repository";
const removeGame = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => gameRepository.delete({ id: gameId });
registerEvent(removeGame, {
name: "removeGame",
});

View file

@ -0,0 +1,37 @@
import { imageCacheRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { getImageBase64 } from "@main/helpers";
import { logger } from "@main/services";
const getOrCacheImage = async (
_event: Electron.IpcMainInvokeEvent,
url: string
) => {
const cache = await imageCacheRepository.findOne({
where: {
url,
},
});
if (cache) return cache.data;
getImageBase64(url).then((data) =>
imageCacheRepository
.save({
url,
data,
})
.catch(() => {
logger.error(`Failed to cache image "${url}"`, {
method: "getOrCacheImage",
});
})
);
return url;
};
registerEvent(getOrCacheImage, {
name: "getOrCacheImage",
});

View file

@ -0,0 +1,12 @@
import { dialog } from "electron";
import { WindowManager } from "@main/services";
import { registerEvent } from "../register-event";
const showOpenDialog = async (
_event: Electron.IpcMainInvokeEvent,
options: Electron.OpenDialogOptions
) => dialog.showOpenDialog(WindowManager.mainWindow, options);
registerEvent(showOpenDialog, {
name: "showOpenDialog",
});

View file

@ -0,0 +1,39 @@
import { ipcMain } from "electron";
import { stateManager } from "@main/state-manager";
interface EventArgs {
name: string;
memoize?: boolean;
}
export const registerEvent = (
listener: (event: Electron.IpcMainInvokeEvent, ...args: any[]) => any,
{ name, memoize = false }: EventArgs
) => {
ipcMain.handle(name, (event: Electron.IpcMainInvokeEvent, ...args) => {
const eventResults = stateManager.getValue("eventResults");
const keys = Array.from(eventResults.keys());
const key = [name, args] as [string, any[]];
const memoizationKey = keys.find(([memoizedEvent, memoizedArgs]) => {
const sameEvent = name === memoizedEvent;
const sameArgs = memoizedArgs.every((arg, index) => arg === args[index]);
return sameEvent && sameArgs;
});
if (memoizationKey) return eventResults.get(memoizationKey);
return Promise.resolve(listener(event, ...args)).then((result) => {
if (memoize) {
eventResults.set(key, JSON.parse(JSON.stringify(result)));
stateManager.setValue("eventResults", eventResults);
}
if (!result) return result;
return JSON.parse(JSON.stringify(result));
});
});
};

View file

@ -0,0 +1,53 @@
import { GameStatus } from "@main/constants";
import { gameRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import { WindowManager, writePipe } from "@main/services";
import { In } from "typeorm";
const cancelGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
GameStatus.Paused,
GameStatus.Seeding,
]),
},
});
if (!game) return;
gameRepository
.update(
{
id: game.id,
},
{
status: GameStatus.Cancelled,
downloadPath: null,
bytesDownloaded: 0,
progress: 0,
}
)
.then((result) => {
if (
game.status !== GameStatus.Paused &&
game.status !== GameStatus.Seeding
) {
writePipe.write({ action: "cancel" });
if (result.affected) WindowManager.mainWindow.setProgressBar(-1);
}
});
};
registerEvent(cancelGameDownload, {
name: "cancelGameDownload",
});

View file

@ -0,0 +1,34 @@
import { WindowManager, writePipe } from "@main/services";
import { registerEvent } from "../register-event";
import { GameStatus } from "../../constants";
import { gameRepository } from "../../repository";
import { In } from "typeorm";
const pauseGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
await gameRepository
.update(
{
id: gameId,
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
)
.then((result) => {
if (result.affected) {
writePipe.write({ action: "pause" });
WindowManager.mainWindow.setProgressBar(-1);
}
});
};
registerEvent(pauseGameDownload, {
name: "pauseGameDownload",
});

View file

@ -0,0 +1,56 @@
import { registerEvent } from "../register-event";
import { GameStatus } from "../../constants";
import { gameRepository } from "../../repository";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { In } from "typeorm";
import { writePipe } from "@main/services";
const resumeGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
gameId: number
) => {
const game = await gameRepository.findOne({
where: {
id: gameId,
},
relations: { repack: true },
});
if (!game) return;
writePipe.write({ action: "pause" });
if (game.status === GameStatus.Paused) {
const downloadsPath = game.downloadPath ?? (await getDownloadsPath());
writePipe.write({
action: "start",
game_id: gameId,
magnet: game.repack.magnet,
save_path: downloadsPath,
});
await gameRepository.update(
{
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
);
await gameRepository.update(
{ id: game.id },
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
}
);
}
};
registerEvent(resumeGameDownload, {
name: "resumeGameDownload",
});

View file

@ -0,0 +1,110 @@
import { getSteamGameIconUrl, writePipe } from "@main/services";
import { gameRepository, repackRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { registerEvent } from "../register-event";
import type { GameShop } from "@types";
import { getDownloadsPath } from "../helpers/get-downloads-path";
import { getImageBase64 } from "@main/helpers";
import { In } from "typeorm";
const startGameDownload = async (
_event: Electron.IpcMainInvokeEvent,
repackId: number,
objectID: string,
title: string,
gameShop: GameShop
) => {
const [game, repack] = await Promise.all([
gameRepository.findOne({
where: {
objectID,
},
}),
repackRepository.findOne({
where: {
id: repackId,
},
}),
]);
if (!repack) return;
if (game?.status === GameStatus.Downloading) {
return;
}
writePipe.write({ action: "pause" });
const downloadsPath = game?.downloadPath ?? (await getDownloadsPath());
await gameRepository.update(
{
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
{ status: GameStatus.Paused }
);
if (game) {
await gameRepository.update(
{
id: game.id,
},
{
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
repack: { id: repackId },
}
);
writePipe.write({
action: "start",
game_id: game.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
game.status = GameStatus.DownloadingMetadata;
writePipe.write({
action: "start",
game_id: game.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
return game;
} else {
const iconUrl = await getImageBase64(await getSteamGameIconUrl(objectID));
const createdGame = await gameRepository.save({
title,
iconUrl,
objectID,
shop: gameShop,
status: GameStatus.DownloadingMetadata,
downloadPath: downloadsPath,
repack: { id: repackId },
});
writePipe.write({
action: "start",
game_id: createdGame.id,
magnet: repack.magnet,
save_path: downloadsPath,
});
const { repack: _, ...rest } = createdGame;
return rest;
}
};
registerEvent(startGameDownload, {
name: "startGameDownload",
});

View file

@ -0,0 +1,11 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
const getUserPreferences = async (_event: Electron.IpcMainInvokeEvent) =>
userPreferencesRepository.findOne({
where: { id: 1 },
});
registerEvent(getUserPreferences, {
name: "getUserPreferences",
});

View file

@ -0,0 +1,21 @@
import { userPreferencesRepository } from "@main/repository";
import { registerEvent } from "../register-event";
import type { UserPreferences } from "@types";
const updateUserPreferences = async (
_event: Electron.IpcMainInvokeEvent,
preferences: Partial<UserPreferences>
) => {
await userPreferencesRepository.upsert(
{
id: 1,
...preferences,
},
["id"]
);
};
registerEvent(updateUserPreferences, {
name: "updateUserPreferences",
});

View file

@ -0,0 +1,98 @@
import assert from "node:assert/strict";
import { describe, test } from "node:test";
import {
dodiFormatter,
empressFormatter,
fitGirlFormatter,
kaosKrewFormatter,
} from "./formatters";
describe("testing formatters", () => {
describe("testing fitgirl formatter", () => {
const fitGirlGames = [
"REVEIL (v1.0.3f4 + 0.5 DLC, MULTi14) [FitGirl Repack]",
"Dune: Spice Wars - The Ixian Edition (v2.0.0.31558 + DLC, MULTi9) [FitGirl Repack]",
"HUMANKIND: Premium Edition (v1.0.22.3819 + 17 DLCs/Bonus Content, MULTi12) [FitGirl Repack, Selective Download - from 7.3 GB]",
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle (v1.034 Hotfix 3 + 3 DLCs, MULTi9) [FitGirl Repack, Selective Download - from 21.8 GB]",
"SUPER BOMBERMAN R 2 (v1.2.0, MULTi12) [FitGirl Repack]",
"God of Rock (v3110, MULTi11) [FitGirl Repack]",
];
test("should format games correctly", () => {
assert.equal(fitGirlGames.map(fitGirlFormatter), [
"REVEIL",
"Dune: Spice Wars - The Ixian Edition",
"HUMANKIND: Premium Edition",
"Call to Arms: Gates of Hell - Ostfront: WW2 Bundle",
"SUPER BOMBERMAN R 2",
"God of Rock",
]);
});
});
describe("testing kaoskrew formatter", () => {
const kaosKrewGames = [
"Song.Of.Horror.Complete.Edition.v1.25.MULTi4.REPACK-KaOs",
"Remoteness.REPACK-KaOs",
"Persona.5.Royal.v1.0.0.MULTi5.NSW.For.PC.REPACK-KaOs",
"The.Wreck.MULTi5.REPACK-KaOs",
"Nemezis.Mysterious.Journey.III.v1.04.Deluxe.Edition.REPACK-KaOs",
"The.World.Of.Others.v1.05.REPACK-KaOs",
];
test("should format games correctly", () => {
assert.equal(kaosKrewGames.map(kaosKrewFormatter), [
"Song Of Horror Complete Edition",
"Remoteness",
"Persona 5 Royal NSW For PC",
"The Wreck",
"Nemezis Mysterious Journey III Deluxe Edition",
"The World Of Others",
]);
});
});
describe("testing empress formatter", () => {
const empressGames = [
"Resident.Evil.4-EMPRESS",
"Marvels.Guardians.of.the.Galaxy.Crackfix-EMPRESS",
"Life.is.Strange.2.Complete.Edition-EMPRESS",
"Forza.Horizon.4.PROPER-EMPRESS",
"Just.Cause.4.Complete.Edition.READNFO-EMPRESS",
"Immortals.Fenyx.Rising.Crackfix.V2-EMPRESS",
];
test("should format games correctly", () => {
assert.equal(empressGames.map(empressFormatter), [
"Resident Evil 4",
"Marvels Guardians of the Galaxy",
"Life is Strange 2 Complete Edition",
"Forza Horizon 4 PROPER",
"Just Cause 4 Complete Edition",
"Immortals Fenyx Rising",
]);
});
});
describe("testing kodi formatter", () => {
const dodiGames = [
"Tomb Raider I-III Remastered Starring Lara Croft (MULTi20) (From 2.5 GB) [DODI Repack]",
"Trail Out: Complete Edition (v2.9st + All DLCs + MULTi11) [DODI Repack]",
"Call to Arms - Gates of Hell: Ostfront (v1.034.0 + All DLCs + MULTi9) (From 22.4 GB) [DODI Repack]",
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition (Digital book + MULTi6) [DODI Repack]",
"DREDGE: Digital Deluxe Edition (v1.2.0.1922 + All DLCs + Bonus Content + MULTi11) (From 413 MB) [DODI Repack]",
"Outliver: Tribulation [DODI Repack]",
];
test("should format games correctly", () => {
assert.equal(dodiGames.map(dodiFormatter), [
"Tomb Raider I-III Remastered Starring Lara Croft",
"Trail Out: Complete Edition",
"Call to Arms - Gates of Hell: Ostfront",
"Metal Gear Solid 2: Sons of Liberty - HD Master Collection Edition",
"DREDGE: Digital Deluxe Edition",
"Outliver: Tribulation",
]);
});
});
});

View file

@ -0,0 +1,54 @@
/* String formatting */
export const removeReleaseYearFromName = (name: string) =>
name.replace(/\([0-9]{4}\)/g, "");
export const removeSymbolsFromName = (name: string) =>
name.replace(/[^A-Za-z 0-9]/g, "");
export const removeSpecialEditionFromName = (name: string) =>
name.replace(
/(The |Digital )?(GOTY|Deluxe|Standard|Ultimate|Definitive|Enhanced|Collector's|Premium|Digital|Limited|Game of the Year|Reloaded|[0-9]{4}) Edition/g,
""
);
export const removeDuplicateSpaces = (name: string) =>
name.replace(/\s{2,}/g, " ");
export const removeTrash = (title: string) =>
title.replace(/\(.*\)|\[.*]/g, "").replace(/:/g, "");
/* Formatters per repacker */
export const fitGirlFormatter = (title: string) =>
title.replace(/\(.*\)/g, "").trim();
export const kaosKrewFormatter = (title: string) =>
title
.replace(/(v\.?[0-9])+([0-9]|\.)+/, "")
.replace(
/(\.Build\.[0-9]*)?(\.MULTi[0-9]{1,2})?(\.REPACK-KaOs|\.UPDATE-KaOs)?/g,
""
)
.replace(/\./g, " ")
.trim();
export const empressFormatter = (title: string) =>
title
.replace(/-EMPRESS/, "")
.replace(/\./g, " ")
.trim();
export const dodiFormatter = (title: string) =>
title.replace(/\(.*?\)/g, "").trim();
export const xatabFormatter = (title: string) =>
title
.replace(/RePack от xatab|RePack от Decepticon|R.G. GOGFAN/, "")
.replace(/[\u0400-\u04FF]/g, "")
.replace(/(v\.?([0-9]| )+)+([0-9]|\.|-|_|\/|[a-zA-Z]| )+/, "");
export const tinyRepacksFormatter = (title: string) => title;
export const gogFormatter = (title: string) =>
title.replace(/(v\.[0-9]+|v[0-9]+\.|v[0-9]{4})+.+/, "");

83
src/main/helpers/index.ts Normal file
View file

@ -0,0 +1,83 @@
import {
removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName,
empressFormatter,
kaosKrewFormatter,
fitGirlFormatter,
removeDuplicateSpaces,
dodiFormatter,
removeTrash,
xatabFormatter,
tinyRepacksFormatter,
gogFormatter,
} from "./formatters";
import { months, repackers } from "../constants";
export const pipe =
<T>(...fns: ((arg: T) => any)[]) =>
(arg: T) =>
fns.reduce((prev, fn) => fn(prev), arg);
export const formatName = pipe<string>(
removeTrash,
removeReleaseYearFromName,
removeSymbolsFromName,
removeSpecialEditionFromName,
removeDuplicateSpaces,
(str) => str.trim()
);
export const repackerFormatter: Record<
(typeof repackers)[number],
(title: string) => string
> = {
DODI: dodiFormatter,
"0xEMPRESS": empressFormatter,
KaOsKrew: kaosKrewFormatter,
FitGirl: fitGirlFormatter,
Xatab: xatabFormatter,
CPG: (title: string) => title,
TinyRepacks: tinyRepacksFormatter,
GOG: gogFormatter,
};
export const formatUploadDate = (str: string) => {
const date = new Date();
const [month, day, year] = str.split(" ");
date.setMonth(months.indexOf(month.replace(".", "")));
date.setDate(Number(day.substring(0, 2)));
date.setFullYear(Number("20" + year.replace("'", "")));
date.setHours(0, 0, 0, 0);
return date;
};
export const getSteamAppAsset = (
category: "library" | "hero" | "logo" | "icon",
objectID: string,
clientIcon?: string
) => {
if (category === "library")
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/header.jpg`;
if (category === "hero")
return `https://steamcdn-a.akamaihd.net/steam/apps/${objectID}/library_hero.jpg`;
if (category === "logo")
return `https://cdn.cloudflare.steamstatic.com/steam/apps/${objectID}/logo.png`;
return `https://cdn.cloudflare.steamstatic.com/steamcommunity/public/images/apps/${objectID}/${clientIcon}.ico`;
};
export const getImageBase64 = async (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => {
return `data:image/jpeg;base64,${Buffer.from(buffer).toString("base64")}`;
})
);
export * from "./formatters";
export * from "./ps";

12
src/main/helpers/ps.ts Normal file
View file

@ -0,0 +1,12 @@
import psList from "ps-list";
import { tasklist } from "tasklist";
export const getProcesses = async () => {
if (process.platform === "win32") {
return tasklist().then((tasks) =>
tasks.map((task) => ({ ...task, name: task.imageName }))
);
}
return psList();
};

118
src/main/index.ts Normal file
View file

@ -0,0 +1,118 @@
import { stateManager } from "./state-manager";
import { GameStatus, repackers } from "./constants";
import {
getNewGOGGames,
getNewRepacksFromCPG,
getNewRepacksFromUser,
getNewRepacksFromXatab,
readPipe,
startProcessWatcher,
writePipe,
} from "./services";
import {
gameRepository,
repackRepository,
repackerFriendlyNameRepository,
userPreferencesRepository,
} from "./repository";
import { TorrentClient } from "./services/torrent-client";
import { Repack } from "./entity";
import { Notification } from "electron";
import { t } from "i18next";
import { In } from "typeorm";
startProcessWatcher();
TorrentClient.startTorrentClient(writePipe.socketPath, readPipe.socketPath);
Promise.all([writePipe.createPipe(), readPipe.createPipe()]).then(async () => {
const game = await gameRepository.findOne({
where: {
status: In([
GameStatus.Downloading,
GameStatus.DownloadingMetadata,
GameStatus.CheckingFiles,
]),
},
relations: { repack: true },
});
if (game) {
writePipe.write({
action: "start",
game_id: game.id,
magnet: game.repack.magnet,
save_path: game.downloadPath,
});
}
readPipe.socket.on("data", (data) => {
TorrentClient.onSocketData(data);
});
});
const track1337xUsers = async (existingRepacks: Repack[]) => {
for (const repacker of repackers) {
await getNewRepacksFromUser(
repacker,
existingRepacks.filter((repack) => repack.repacker === repacker)
);
}
};
const checkForNewRepacks = async () => {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
const existingRepacks = stateManager.getValue("repacks");
Promise.allSettled([
getNewGOGGames(
existingRepacks.filter((repack) => repack.repacker === "GOG")
),
getNewRepacksFromXatab(
existingRepacks.filter((repack) => repack.repacker === "Xatab")
),
getNewRepacksFromCPG(
existingRepacks.filter((repack) => repack.repacker === "CPG")
),
track1337xUsers(existingRepacks),
]).then(() => {
repackRepository.count().then((count) => {
const total = count - stateManager.getValue("repacks").length;
if (total > 0 && userPreferences.repackUpdatesNotificationsEnabled) {
new Notification({
title: t("repack_list_updated", {
ns: "notifications",
lng: userPreferences?.language || "en",
}),
body: t("repack_count", {
ns: "notifications",
lng: userPreferences?.language || "en",
count: total,
}),
}).show();
}
});
});
};
const loadState = async () => {
const [friendlyNames, repacks] = await Promise.all([
repackerFriendlyNameRepository.find(),
repackRepository.find({
order: {
createdAt: "desc",
},
}),
]);
stateManager.setValue("repackersFriendlyNames", friendlyNames);
stateManager.setValue("repacks", repacks);
import("./events");
};
loadState().then(() => checkForNewRepacks());

27
src/main/repository.ts Normal file
View file

@ -0,0 +1,27 @@
import { dataSource } from "./data-source";
import {
Game,
GameShopCache,
ImageCache,
Repack,
RepackerFriendlyName,
UserPreferences,
MigrationScript,
} from "@main/entity";
export const gameRepository = dataSource.getRepository(Game);
export const imageCacheRepository = dataSource.getRepository(ImageCache);
export const repackRepository = dataSource.getRepository(Repack);
export const repackerFriendlyNameRepository =
dataSource.getRepository(RepackerFriendlyName);
export const userPreferencesRepository =
dataSource.getRepository(UserPreferences);
export const gameShopCacheRepository = dataSource.getRepository(GameShopCache);
export const migrationScriptRepository =
dataSource.getRepository(MigrationScript);

38
src/main/services/fifo.ts Normal file
View file

@ -0,0 +1,38 @@
import path from "node:path";
import net from "node:net";
import crypto from "node:crypto";
import os from "node:os";
export class FIFO {
public socket: null | net.Socket = null;
public socketPath = this.generateSocketFilename();
private generateSocketFilename() {
const hash = crypto.randomBytes(16).toString("hex");
if (process.platform === "win32") {
return "\\\\.\\pipe\\" + hash;
}
return path.join(os.tmpdir(), hash);
}
public write(data: any) {
if (!this.socket) return;
this.socket.write(Buffer.from(JSON.stringify(data)));
}
public createPipe() {
return new Promise((resolve) => {
const server = net.createServer((socket) => {
this.socket = socket;
resolve(null);
});
server.listen(this.socketPath);
});
}
}
export const writePipe = new FIFO();
export const readPipe = new FIFO();

View file

@ -0,0 +1,60 @@
import { formatName } from "@main/helpers";
import axios from "axios";
import { JSDOM } from "jsdom";
import { requestWebPage } from "./repack-tracker/helpers";
import { HowLongToBeatCategory } from "@types";
export interface HowLongToBeatResult {
game_id: number;
profile_steam: number;
}
export interface HowLongToBeatSearchResponse {
data: HowLongToBeatResult[];
}
export const searchHowLongToBeat = async (gameName: string) => {
const response = await axios.post(
"https://howlongtobeat.com/api/search",
{
searchType: "games",
searchTerms: formatName(gameName).split(" "),
searchPage: 1,
size: 100,
},
{
headers: {
"User-Agent":
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36",
Referer: "https://howlongtobeat.com/",
},
}
);
return response.data as HowLongToBeatSearchResponse;
};
export const getHowLongToBeatGame = async (
id: string
): Promise<HowLongToBeatCategory[]> => {
const response = await requestWebPage(`https://howlongtobeat.com/game/${id}`);
const { window } = new JSDOM(response);
const { document } = window;
const $ul = document.querySelector(".shadow_shadow ul");
const $lis = Array.from($ul.children);
return $lis.map(($li) => {
const title = $li.querySelector("h4").textContent;
const [, accuracyClassName] = Array.from(($li as HTMLElement).classList);
const accuracy = accuracyClassName.split("time_").at(1);
return {
title,
duration: $li.querySelector("h5").textContent,
accuracy,
};
});
};

View file

@ -0,0 +1,11 @@
export * from "./logger";
export * from "./repack-tracker";
export * from "./steam";
export * from "./steam-250";
export * from "./steam-grid";
export * from "./update-resolver";
export * from "./window-manager";
export * from "./fifo";
export * from "./torrent-client";
export * from "./how-long-to-beat";
export * from "./process-watcher";

View file

@ -0,0 +1,11 @@
import winston from "winston";
export const logger = winston.createLogger({
level: "info",
format: winston.format.json(),
transports: [
new winston.transports.File({ filename: "error.log", level: "error" }),
new winston.transports.File({ filename: "info.log", level: "info" }),
new winston.transports.File({ filename: "combined.log" }),
],
});

View file

@ -0,0 +1,77 @@
import path from "node:path";
import { IsNull, Not } from "typeorm";
import { gameRepository } from "@main/repository";
import { GameStatus } from "@main/constants";
import { getProcesses } from "@main/helpers";
import { WindowManager } from "./window-manager";
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export const startProcessWatcher = async () => {
const sleepTime = 100;
const gamesPlaytime = new Map<number, number>();
// eslint-disable-next-line no-constant-condition
while (true) {
const games = await gameRepository.find({
where: {
executablePath: Not(IsNull()),
status: GameStatus.Seeding,
},
});
const processes = await getProcesses();
for (const game of games) {
const gameProcess = processes.find((runningProcess) => {
const basename = path.win32.basename(game.executablePath);
const basenameWithoutExtension = path.win32.basename(
game.executablePath,
path.extname(game.executablePath)
);
if (process.platform === "win32") {
return runningProcess.name === basename;
}
return [basename, basenameWithoutExtension].includes(
runningProcess.name
);
});
if (gameProcess) {
if (gamesPlaytime.has(game.id)) {
const zero = gamesPlaytime.get(game.id);
const delta = performance.now() - zero;
WindowManager.mainWindow.webContents.send("on-playtime", game.id);
await gameRepository.update(game.id, {
playTimeInMilliseconds: game.playTimeInMilliseconds + delta,
});
gamesPlaytime.set(game.id, performance.now());
await sleep(sleepTime);
continue;
}
gamesPlaytime.set(game.id, performance.now());
gameRepository.update(game.id, {
lastTimePlayed: new Date().toUTCString(),
});
await sleep(sleepTime);
continue;
}
if (gamesPlaytime.has(game.id)) {
gamesPlaytime.delete(game.id);
WindowManager.mainWindow.webContents.send("on-game-close", game.id);
}
await sleep(sleepTime);
}
}
};

View file

@ -0,0 +1,135 @@
import { JSDOM } from "jsdom";
import { formatUploadDate } from "@main/helpers";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
export const request1337x = async (path: string) =>
requestWebPage(`https://1337xx.to${path}`);
/* TODO: $a will often be null */
const getTorrentDetails = async (path: string) => {
const response = await request1337x(path);
const { window } = new JSDOM(response);
const { document } = window;
const $a = window.document.querySelector(
".torrentdown1"
) as HTMLAnchorElement;
const $ul = Array.from(
document.querySelectorAll(".torrent-detail-page .list")
);
const [$firstColumn, $secondColumn] = $ul;
if (!$firstColumn || !$secondColumn) {
return { magnet: $a?.href };
}
const [_$category, _$type, _$language, $totalSize] = $firstColumn.children;
const [_$downloads, _$lastChecked, $dateUploaded] = $secondColumn.children;
return {
magnet: $a?.href,
fileSize: $totalSize.querySelector("span").textContent ?? undefined,
uploadDate: formatUploadDate(
$dateUploaded.querySelector("span").textContent!
),
};
};
export const getTorrentListLastPage = async (user: string) => {
const response = await request1337x(`/user/${user}/1`);
const { window } = new JSDOM(response);
const $ul = window.document.querySelector(".pagination > ul");
if ($ul) {
const $li = Array.from($ul.querySelectorAll("li")).at(-1);
const text = $li?.textContent;
if (text === ">>") {
const $previousLi = Array.from($ul.querySelectorAll("li")).at(-2);
return Number($previousLi?.textContent);
}
return Number(text);
}
return -1;
};
export const extractTorrentsFromDocument = async (
page: number,
user: string,
document: Document,
existingRepacks: Repack[] = []
): Promise<GameRepackInput[]> => {
const $trs = Array.from(document.querySelectorAll("tbody tr"));
return Promise.all(
$trs.map(async ($tr) => {
const $td = $tr.querySelector("td");
const [, $name] = Array.from($td!.querySelectorAll("a"));
const url = $name.href;
const title = $name.textContent ?? "";
if (existingRepacks.some((repack) => repack.title === title)) {
return {
title,
magnet: "",
fileSize: null,
uploadDate: null,
repacker: user,
page,
};
}
const details = await getTorrentDetails(url);
return {
title,
magnet: details.magnet,
fileSize: details.fileSize ?? null,
uploadDate: details.uploadDate ?? null,
repacker: user,
page,
};
})
);
};
export const getNewRepacksFromUser = async (
user: string,
existingRepacks: Repack[],
page = 1
): Promise<Repack[]> => {
const response = await request1337x(`/user/${user}/${page}`);
const { window } = new JSDOM(response);
const repacks = await extractTorrentsFromDocument(
page,
user,
window.document,
existingRepacks
);
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromUser(user, existingRepacks, page + 1);
};

View file

@ -0,0 +1,65 @@
import { JSDOM } from "jsdom";
import { Repack } from "@main/entity";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
import { logger } from "../logger";
export const getNewRepacksFromCPG = async (
existingRepacks: Repack[] = [],
page = 1
): Promise<void> => {
const data = await requestWebPage(`https://cpgrepacks.site/page/${page}`);
const { window } = new JSDOM(data);
const repacks: GameRepackInput[] = [];
try {
Array.from(window.document.querySelectorAll(".post")).forEach(($post) => {
const $title = $post.querySelector(".entry-title");
const uploadDate = $post.querySelector("time").getAttribute("datetime");
const $downloadInfo = Array.from(
$post.querySelectorAll(".wp-block-heading")
).find(($heading) => $heading.textContent.startsWith("Download"));
/* Side note: CPG often misspells "Magnet" as "Magent" */
const $magnet = Array.from($post.querySelectorAll("a")).find(
($a) =>
$a.textContent.startsWith("Magnet") ||
$a.textContent.startsWith("Magent")
);
const fileSize = $downloadInfo.textContent
.split("Download link => ")
.at(1);
repacks.push({
title: $title.textContent,
fileSize: fileSize ?? "N/A",
magnet: $magnet.href,
repacker: "CPG",
page,
uploadDate: new Date(uploadDate),
});
});
} catch (err) {
logger.error(err.message, { method: "getNewRepacksFromCPG" });
}
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromCPG(existingRepacks, page + 1);
};

View file

@ -0,0 +1,78 @@
import { JSDOM, VirtualConsole } from "jsdom";
import { GameRepackInput, requestWebPage, savePage } from "./helpers";
import { Repack } from "@main/entity";
import { logger } from "../logger";
const virtualConsole = new VirtualConsole();
const getGOGGame = async (url: string) => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data, { virtualConsole });
const $modifiedTime = window.document.querySelector(
'[property="article:modified_time"]'
) as HTMLMetaElement;
const $em = window.document.querySelector(
"p:not(.lightweight-accordion *) em"
);
const fileSize = $em.textContent.split("Size: ").at(1);
const $downloadButton = window.document.querySelector(
".download-btn:not(.lightweight-accordion *)"
) as HTMLAnchorElement;
const { searchParams } = new URL($downloadButton.href);
const magnet = Buffer.from(searchParams.get("url"), "base64").toString(
"utf-8"
);
return {
fileSize: fileSize ?? "N/A",
uploadDate: new Date($modifiedTime.content),
repacker: "GOG",
magnet,
page: 1,
};
};
export const getNewGOGGames = async (existingRepacks: Repack[] = []) => {
try {
const data = await requestWebPage(
"https://freegogpcgames.com/a-z-games-list/"
);
const { window } = new JSDOM(data, { virtualConsole });
const $uls = Array.from(window.document.querySelectorAll(".az-columns"));
for (const $ul of $uls) {
const repacks: GameRepackInput[] = [];
const $lis = Array.from($ul.querySelectorAll("li"));
for (const $li of $lis) {
const $a = $li.querySelector("a");
const href = $a.href;
const title = $a.textContent.trim();
const gameExists = existingRepacks.some(
(existingRepack) => existingRepack.title === title
);
if (!gameExists) {
try {
const game = await getGOGGame(href);
repacks.push({ ...game, title });
} catch (err) {
logger.error(err.message, { method: "getGOGGame", url: href });
}
}
}
if (repacks.length) await savePage(repacks);
}
} catch (err) {
logger.error(err.message, { method: "getNewGOGGames" });
}
};

View file

@ -0,0 +1,18 @@
import { repackRepository } from "@main/repository";
import type { GameRepack } from "@types";
export type GameRepackInput = Omit<
GameRepack,
"id" | "repackerFriendlyName" | "createdAt" | "updatedAt"
>;
export const savePage = async (repacks: GameRepackInput[]) =>
Promise.all(
repacks.map((repack) => repackRepository.insert(repack).catch(() => {}))
);
export const requestWebPage = async (url: string) =>
fetch(url, {
method: "GET",
}).then((response) => response.text());

View file

@ -0,0 +1,4 @@
export * from "./1337x";
export * from "./xatab";
export * from "./cpg-repacks";
export * from "./gog";

View file

@ -0,0 +1,95 @@
import { JSDOM } from "jsdom";
import parseTorrent, { toMagnetURI } from "parse-torrent";
import { Repack } from "@main/entity";
import { logger } from "../logger";
import { requestWebPage, savePage } from "./helpers";
import type { GameRepackInput } from "./helpers";
const getTorrentBuffer = (url: string) =>
fetch(url, { method: "GET" }).then((response) =>
response.arrayBuffer().then((buffer) => Buffer.from(buffer))
);
const formatXatabDate = (str: string) => {
const date = new Date();
const [day, month, year] = str.split(".");
date.setDate(Number(day));
date.setMonth(Number(month) - 1);
date.setFullYear(Number(year));
date.setHours(0, 0, 0, 0);
return date;
};
const formatXatabDownloadSize = (str: string) =>
str.replace(",", ".").replace(/Гб/g, "GB").replace(/Мб/g, "MB");
const getXatabRepack = async (url: string) => {
const data = await requestWebPage(url);
const { window } = new JSDOM(data);
const $uploadDate = window.document.querySelector(".entry__date");
const $size = window.document.querySelector(".entry__info-size");
const $downloadButton = window.document.querySelector(
".download-torrent"
) as HTMLAnchorElement;
if (!$downloadButton) throw new Error("Download button not found");
const torrentBuffer = await getTorrentBuffer($downloadButton.href);
return {
fileSize: formatXatabDownloadSize($size.textContent).toUpperCase(),
magnet: toMagnetURI({
infoHash: parseTorrent(torrentBuffer).infoHash,
}),
uploadDate: formatXatabDate($uploadDate.textContent),
};
};
export const getNewRepacksFromXatab = async (
existingRepacks: Repack[] = [],
page = 1
): Promise<void> => {
const data = await requestWebPage(`https://byxatab.com/page/${page}`);
const { window } = new JSDOM(data);
const repacks: GameRepackInput[] = [];
for (const $a of Array.from(
window.document.querySelectorAll(".entry__title a")
)) {
try {
const repack = await getXatabRepack(($a as HTMLAnchorElement).href);
repacks.push({
title: $a.textContent,
repacker: "Xatab",
...repack,
page,
});
} catch (err) {
logger.error(err.message, { method: "getNewRepacksFromXatab" });
}
}
const newRepacks = repacks.filter(
(repack) =>
repack.uploadDate &&
!existingRepacks.some(
(existingRepack) => existingRepack.title === repack.title
)
);
if (!newRepacks.length) return;
await savePage(newRepacks);
return getNewRepacksFromXatab(existingRepacks, page + 1);
};

View file

@ -0,0 +1,46 @@
import axios from "axios";
import { JSDOM } from "jsdom";
import shuffle from "lodash/shuffle";
import { logger } from "./logger";
const requestSteam250 = async (path: string) => {
return axios
.get(`https://steam250.com${path}`)
.then((response) => response.data);
};
export const getTrendingGames = async () => {
const response = await requestSteam250("/365day").catch((err) => {
logger.error(err.response, { method: "getTrendingGames" });
throw new Error(err);
});
const { window } = new JSDOM(response);
const { document } = window;
return Array.from(document.querySelectorAll(".appline .title a")).map(
($title) => $title.textContent!
);
};
const steam250Paths = [
"/hidden_gems",
`/${new Date().getFullYear()}`,
"/top250",
"/most_played",
];
export const getRandomSteam250List = async () => {
const [path] = shuffle(steam250Paths);
const response = await requestSteam250(path).catch((err) => {
logger.error(err.response, { method: "getRandomSteam250List" });
throw new Error(err);
});
const { window } = new JSDOM(response);
const { document } = window;
return Array.from(document.querySelectorAll(".appline .title a")).map(
($title) => $title.textContent!
);
};

View file

@ -0,0 +1,71 @@
import { getSteamAppAsset } from "@main/helpers";
export interface SteamGridResponse {
success: boolean;
data: {
id: number;
};
}
export interface SteamGridGameResponse {
data: {
platforms: {
steam: {
metadata: {
clienticon: string;
};
};
};
};
}
export const getSteamGridData = async (
objectID: string,
path: string,
shop: string,
params: Record<string, string> = {}
): Promise<SteamGridResponse> => {
const searchParams = new URLSearchParams(params);
const response = await fetch(
`https://www.steamgriddb.com/api/v2/${path}/${shop}/${objectID}?${searchParams.toString()}`,
{
method: "GET",
headers: {
Authorization: `Bearer ${process.env.STEAMGRIDDB_API_KEY}`,
},
}
);
return response.json();
};
export const getSteamGridGameById = async (
id: number
): Promise<SteamGridGameResponse> => {
const response = await fetch(
`https://www.steamgriddb.com/api/public/game/${id}`,
{
method: "GET",
headers: {
Referer: "https://www.steamgriddb.com/",
},
}
);
return response.json();
};
export const getSteamGameIconUrl = async (objectID: string) => {
const {
data: { id: steamGridGameId },
} = await getSteamGridData(objectID, "games", "steam");
const steamGridGame = await getSteamGridGameById(steamGridGameId);
return getSteamAppAsset(
"icon",
objectID,
steamGridGame.data.platforms.steam.metadata.clienticon
);
};

View file

@ -0,0 +1,78 @@
import axios from "axios";
import { JSDOM } from "jsdom";
import type { SteamAppDetails } from "@types";
import { logger } from "./logger";
export interface SteamAppDetailsResponse {
[key: string]: {
success: boolean;
data: SteamAppDetails;
};
}
export const getSteamAppDetails = async (
objectID: string,
language: string
) => {
const searchParams = new URLSearchParams({
appids: objectID,
l: language,
});
return axios
.get(
`http://store.steampowered.com/api/appdetails?${searchParams.toString()}`
)
.then((response) => {
if (response.data[objectID].success) return response.data[objectID].data;
return null;
})
.catch((err) => {
logger.error(err, { method: "getSteamAppDetails" });
throw new Error(err);
});
};
export const searchSteamGame = async (term: string) => {
const searchParams = new URLSearchParams({
start: "0",
count: "12",
sort_by: "_ASC",
/* Games only */
category1: "998",
term: term,
});
const response = await axios.get(
`https://store.steampowered.com/search/results/?${searchParams.toString()}`
);
const { window } = new JSDOM(response.data);
const { document } = window;
const $anchors = Array.from(
document.querySelectorAll("#search_resultsRows a")
);
return $anchors.reduce((prev, $a) => {
const $title = $a.querySelector(".title");
const objectIDs = $a.getAttribute("data-ds-appid");
if (!objectIDs) return prev;
const [objectID] = objectIDs.split(",");
if (!objectID || prev.some((game) => game.objectID === objectID))
return prev;
return [
...prev,
{
name: $title.textContent,
objectID,
},
];
}, []);
};

View file

@ -0,0 +1,161 @@
import path from "node:path";
import cp from "node:child_process";
import * as Sentry from "@sentry/electron/main";
import { Notification, app } from "electron";
import type { QueryDeepPartialEntity } from "typeorm/query-builder/QueryPartialEntity";
import { Game } from "@main/entity";
import { gameRepository, userPreferencesRepository } from "@main/repository";
import { t } from "i18next";
import { WindowManager } from "./window-manager";
const binaryNameByPlatform: Partial<Record<NodeJS.Platform, string>> = {
darwin: "hydra-download-manager",
linux: "hydra-download-manager",
win32: "hydra-download-manager.exe",
};
enum TorrentState {
CheckingFiles = 1,
DownloadingMetadata = 2,
Downloading = 3,
Finished = 4,
Seeding = 5,
}
export interface TorrentUpdate {
gameId: number;
progress: number;
downloadSpeed: number;
timeRemaining: number;
numPeers: number;
numSeeds: number;
status: TorrentState;
folderName: string;
fileSize: number;
bytesDownloaded: number;
}
export const BITTORRENT_PORT = "5881";
export class TorrentClient {
public static startTorrentClient(
writePipePath: string,
readPipePath: string
) {
const commonArgs = [BITTORRENT_PORT, writePipePath, readPipePath];
if (app.isPackaged) {
const binaryName = binaryNameByPlatform[process.platform];
const binaryPath = path.join(
process.resourcesPath,
"dist",
"hydra-download-manager",
binaryName
);
cp.spawn(binaryPath, commonArgs, {
stdio: "inherit",
windowsHide: true,
});
return;
}
const scriptPath = path.join(
__dirname,
"..",
"..",
"torrent-client",
"main.py"
);
cp.spawn("python3", [scriptPath, ...commonArgs], {
stdio: "inherit",
});
}
private static getTorrentStateName(state: TorrentState) {
if (state === TorrentState.CheckingFiles) return "checking_files";
if (state === TorrentState.Downloading) return "downloading";
if (state === TorrentState.DownloadingMetadata)
return "downloading_metadata";
if (state === TorrentState.Finished) return "finished";
if (state === TorrentState.Seeding) return "seeding";
return "";
}
private static getGameProgress(game: Game) {
if (game.status === "checking_files") return game.fileVerificationProgress;
return game.progress;
}
public static async onSocketData(data: Buffer) {
const message = Buffer.from(data).toString("utf-8");
try {
const payload = JSON.parse(message) as TorrentUpdate;
const updatePayload: QueryDeepPartialEntity<Game> = {
bytesDownloaded: payload.bytesDownloaded,
status: this.getTorrentStateName(payload.status),
};
if (payload.status === TorrentState.CheckingFiles) {
updatePayload.fileVerificationProgress = payload.progress;
} else {
if (payload.folderName) {
updatePayload.folderName = payload.folderName;
updatePayload.fileSize = payload.fileSize;
}
}
if (
[TorrentState.Downloading, TorrentState.Seeding].includes(
payload.status
)
) {
updatePayload.progress = payload.progress;
}
await gameRepository.update({ id: payload.gameId }, updatePayload);
const game = await gameRepository.findOne({
where: { id: payload.gameId },
relations: { repack: true },
});
if (game.progress === 1) {
const userPreferences = await userPreferencesRepository.findOne({
where: { id: 1 },
});
if (userPreferences?.downloadNotificationsEnabled) {
new Notification({
title: t("download_complete", {
ns: "notifications",
lng: userPreferences.language,
}),
body: t("game_ready_to_install", {
ns: "notifications",
lng: userPreferences.language,
title: game.title,
}),
}).show();
}
}
if (WindowManager.mainWindow) {
const progress = this.getGameProgress(game);
WindowManager.mainWindow.setProgressBar(progress === 1 ? -1 : progress);
WindowManager.mainWindow.webContents.send(
"on-download-progress",
JSON.parse(JSON.stringify({ ...payload, game }))
);
}
} catch (err) {
Sentry.captureException(err);
Sentry.captureMessage(message, "error");
}
}
}

View file

@ -0,0 +1,144 @@
import path from "node:path";
import { app } from "electron";
import chunk from "lodash/chunk";
import { createDataSource, dataSource } from "@main/data-source";
import { Repack, RepackerFriendlyName } from "@main/entity";
import {
migrationScriptRepository,
repackRepository,
repackerFriendlyNameRepository,
} from "@main/repository";
import { MigrationScript } from "@main/entity/migration-script.entity";
import { Like } from "typeorm";
const migrationScripts = {
/*
0.0.6 -> 0.0.7
Xatab repacks were previously created with an incorrect upload date.
This migration script will update the upload date of all Xatab repacks.
*/
"0.0.7": async (updateRepacks: Repack[]) => {
const VERSION = "0.0.7";
const migrationScript = await migrationScriptRepository.findOne({
where: {
version: VERSION,
},
});
if (!migrationScript) {
const xatabRepacks = updateRepacks.filter(
(repack) => repack.repacker === "Xatab"
);
await dataSource.transaction(async (transactionalEntityManager) => {
await Promise.all(
xatabRepacks.map((repack) =>
transactionalEntityManager.getRepository(Repack).update(
{
title: repack.title,
repacker: repack.repacker,
},
{
uploadDate: repack.uploadDate,
}
)
)
);
await transactionalEntityManager.getRepository(MigrationScript).insert({
version: VERSION,
});
});
}
},
/*
1.0.1 -> 1.1.0
A few torrents scraped from 1337x were previously created with an incorrect upload date.
*/
"1.1.0": async () => {
const VERSION = "1.1.0";
const migrationScript = await migrationScriptRepository.findOne({
where: {
version: VERSION,
},
});
if (!migrationScript) {
await dataSource.transaction(async (transactionalEntityManager) => {
const repacks = await transactionalEntityManager
.getRepository(Repack)
.find({
where: {
uploadDate: Like("1%"),
},
});
await Promise.all(
repacks.map(async (repack) => {
return transactionalEntityManager
.getRepository(Repack)
.update(
{ id: repack.id },
{ uploadDate: new Date(repack.uploadDate) }
);
})
);
await transactionalEntityManager.getRepository(MigrationScript).insert({
version: VERSION,
});
});
}
},
};
export const runMigrationScripts = async (updateRepacks: Repack[]) => {
return Promise.all(
Object.values(migrationScripts).map((migrationScript) => {
return migrationScript(updateRepacks);
})
);
};
export const resolveDatabaseUpdates = async () => {
const updateDataSource = createDataSource({
database: app.isPackaged
? path.join(process.resourcesPath, "hydra.db")
: path.join(__dirname, "..", "..", "resources", "hydra.db"),
});
return updateDataSource.initialize().then(async () => {
const updateRepackRepository = updateDataSource.getRepository(Repack);
const updateRepackerFriendlyNameRepository =
updateDataSource.getRepository(RepackerFriendlyName);
const [updateRepacks, updateRepackerFriendlyNames] = await Promise.all([
updateRepackRepository.find(),
updateRepackerFriendlyNameRepository.find(),
]);
await runMigrationScripts(updateRepacks);
await repackerFriendlyNameRepository
.createQueryBuilder()
.insert()
.values(updateRepackerFriendlyNames)
.orIgnore()
.execute();
const updateRepacksChunks = chunk(updateRepacks, 800);
for (const chunk of updateRepacksChunks) {
await repackRepository
.createQueryBuilder()
.insert()
.values(chunk)
.orIgnore()
.execute();
}
});
};

View file

@ -0,0 +1,107 @@
import { BrowserWindow, Menu, Tray, app } from "electron";
import { t } from "i18next";
import path from "node:path";
// This allows TypeScript to pick up the magic constants that's auto-generated by Forge's Webpack
// plugin that tells the Electron app where to look for the Webpack-bundled app code (depending on
// whether you're running in development or production).
declare const MAIN_WINDOW_WEBPACK_ENTRY: string;
declare const MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY: string;
export class WindowManager {
public static mainWindow: Electron.BrowserWindow | null = null;
public static createMainWindow() {
// Create the browser window.
this.mainWindow = new BrowserWindow({
width: 1200,
height: 720,
minWidth: 1024,
minHeight: 540,
titleBarStyle: "hidden",
icon: path.join(__dirname, "..", "..", "images", "icon.png"),
trafficLightPosition: { x: 16, y: 16 },
titleBarOverlay: {
symbolColor: "#DADBE1",
color: "#151515",
height: 34,
},
webPreferences: {
preload: MAIN_WINDOW_PRELOAD_WEBPACK_ENTRY,
},
});
this.mainWindow.removeMenu();
this.mainWindow.loadURL(MAIN_WINDOW_WEBPACK_ENTRY);
this.mainWindow.webContents.on("did-finish-load", () => {
if (!app.isPackaged) {
// Open the DevTools.
this.mainWindow.webContents.openDevTools();
}
});
this.mainWindow.on("close", () => {
WindowManager.mainWindow.setProgressBar(-1);
});
}
public static redirect(path: string) {
if (!this.mainWindow) this.createMainWindow();
this.mainWindow.loadURL(`${MAIN_WINDOW_WEBPACK_ENTRY}#${path}`);
if (this.mainWindow.isMinimized()) this.mainWindow.restore();
this.mainWindow.focus();
}
public static createSystemTray(language: string) {
const tray = new Tray(
app.isPackaged
? path.join(process.resourcesPath, "icon_tray.png")
: path.join(__dirname, "..", "..", "resources", "icon_tray.png")
);
const contextMenu = Menu.buildFromTemplate([
{
label: t("open", {
ns: "system_tray",
lng: language,
}),
type: "normal",
click: () => {
if (this.mainWindow) {
this.mainWindow.show();
} else {
this.createMainWindow();
}
},
},
{
label: t("quit", {
ns: "system_tray",
lng: language,
}),
type: "normal",
click: () => app.quit(),
},
]);
tray.setToolTip("Hydra");
tray.setContextMenu(contextMenu);
if (process.platform === "win32") {
tray.addListener("click", () => {
if (this.mainWindow) {
if (WindowManager.mainWindow.isMinimized())
WindowManager.mainWindow.restore();
WindowManager.mainWindow.focus();
return;
}
this.createMainWindow();
});
}
}
}

31
src/main/state-manager.ts Normal file
View file

@ -0,0 +1,31 @@
import type { Repack, RepackerFriendlyName } from "@main/entity";
interface State {
repacks: Repack[];
repackersFriendlyNames: RepackerFriendlyName[];
eventResults: Map<[string, any[]], any>;
}
const initialState: State = {
repacks: [],
repackersFriendlyNames: [],
eventResults: new Map(),
};
export class StateManager {
private state = initialState;
public setValue<T extends keyof State>(key: T, value: State[T]) {
this.state = { ...this.state, [key]: value };
}
public getValue<T extends keyof State>(key: T) {
return this.state[key];
}
public clearValue<T extends keyof State>(key: T) {
this.state = { ...this.state, [key]: initialState[key] };
}
}
export const stateManager = new StateManager();

90
src/preload.ts Normal file
View file

@ -0,0 +1,90 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
import { contextBridge, ipcRenderer } from "electron";
import type {
CatalogueCategory,
GameShop,
TorrentProgress,
UserPreferences,
} from "@types";
contextBridge.exposeInMainWorld("electron", {
/* Torrenting */
startGameDownload: (
repackId: number,
objectID: string,
title: string,
shop: GameShop
) => ipcRenderer.invoke("startGameDownload", repackId, objectID, title, shop),
cancelGameDownload: (gameId: number) =>
ipcRenderer.invoke("cancelGameDownload", gameId),
pauseGameDownload: (gameId: number) =>
ipcRenderer.invoke("pauseGameDownload", gameId),
resumeGameDownload: (gameId: number) =>
ipcRenderer.invoke("resumeGameDownload", gameId),
onDownloadProgress: (cb: (value: TorrentProgress) => void) => {
const listener = (
_event: Electron.IpcRendererEvent,
value: TorrentProgress
) => cb(value);
ipcRenderer.on("on-download-progress", listener);
return () => ipcRenderer.removeListener("on-download-progress", listener);
},
/* Catalogue */
searchGames: (query: string) => ipcRenderer.invoke("searchGames", query),
getCatalogue: (category: CatalogueCategory) =>
ipcRenderer.invoke("getCatalogue", category),
getGameShopDetails: (objectID: string, shop: GameShop, language: string) =>
ipcRenderer.invoke("getGameShopDetails", objectID, shop, language),
getRandomGame: () => ipcRenderer.invoke("getRandomGame"),
getHowLongToBeat: (objectID: string, shop: GameShop, title: string) =>
ipcRenderer.invoke("getHowLongToBeat", objectID, shop, title),
/* User preferences */
getUserPreferences: () => ipcRenderer.invoke("getUserPreferences"),
updateUserPreferences: (preferences: UserPreferences) =>
ipcRenderer.invoke("updateUserPreferences", preferences),
/* Library */
addGameToLibrary: (objectID: string, title: string, shop: GameShop) =>
ipcRenderer.invoke("addGameToLibrary", objectID, title, shop),
getLibrary: () => ipcRenderer.invoke("getLibrary"),
getRepackersFriendlyNames: () =>
ipcRenderer.invoke("getRepackersFriendlyNames"),
openGameInstaller: (gameId: number) =>
ipcRenderer.invoke("openGameInstaller", gameId),
openGame: (gameId: number, path: string) =>
ipcRenderer.invoke("openGame", gameId, path),
closeGame: (gameId: number) => ipcRenderer.invoke("closeGame", gameId),
removeGame: (gameId: number) => ipcRenderer.invoke("removeGame", gameId),
deleteGameFolder: (gameId: number) =>
ipcRenderer.invoke("deleteGameFolder", gameId),
getGameByObjectID: (objectID: string) =>
ipcRenderer.invoke("getGameByObjectID", objectID),
onPlaytime: (cb: (gameId: number) => void) => {
const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
cb(gameId);
ipcRenderer.on("on-playtime", listener);
return () => ipcRenderer.removeListener("on-playtime", listener);
},
onGameClose: (cb: (gameId: number) => void) => {
const listener = (_event: Electron.IpcRendererEvent, gameId: number) =>
cb(gameId);
ipcRenderer.on("on-game-close", listener);
return () => ipcRenderer.removeListener("on-game-close", listener);
},
/* Hardware */
getDiskFreeSpace: () => ipcRenderer.invoke("getDiskFreeSpace"),
/* Misc */
getOrCacheImage: (url: string) => ipcRenderer.invoke("getOrCacheImage", url),
ping: () => ipcRenderer.invoke("ping"),
getVersion: () => ipcRenderer.invoke("getVersion"),
getDefaultDownloadsPath: () => ipcRenderer.invoke("getDefaultDownloadsPath"),
showOpenDialog: (options: Electron.OpenDialogOptions) =>
ipcRenderer.invoke("showOpenDialog", options),
platform: process.platform,
});

29
src/renderer.ts Normal file
View file

@ -0,0 +1,29 @@
/**
* This file will automatically be loaded by vite and run in the "renderer" context.
* To learn more about the differences between the "main" and the "renderer" context in
* Electron, visit:
*
* https://electronjs.org/docs/tutorial/application-architecture#main-and-renderer-processes
*
* By default, Node.js integration in this file is disabled. When enabling Node.js integration
* in a renderer process, please be aware of potential security implications. You can read
* more about security risks here:
*
* https://electronjs.org/docs/tutorial/security
*
* To enable Node.js integration in this file, open up `main.ts` and enable the `nodeIntegration`
* flag:
*
* ```
* // Create the browser window.
* mainWindow = new BrowserWindow({
* width: 800,
* height: 600,
* webPreferences: {
* nodeIntegration: true
* }
* });
* ```
*/
import "./renderer/main";

107
src/renderer/app.css.ts Normal file
View file

@ -0,0 +1,107 @@
import { ComplexStyleRule, globalStyle, style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "./theme.css";
globalStyle("*", {
boxSizing: "border-box",
});
globalStyle("::-webkit-scrollbar", {
width: "9px",
});
globalStyle("::-webkit-scrollbar-track", {
backgroundColor: "rgba(255, 255, 255, 0.03)",
});
globalStyle("::-webkit-scrollbar-thumb", {
backgroundColor: "rgba(255, 255, 255, 0.08)",
borderRadius: "24px",
});
globalStyle("html, body, #root, main", {
height: "100%",
});
globalStyle("body", {
overflow: "hidden",
userSelect: "none",
fontFamily: "'Fira Mono', monospace",
background: vars.color.background,
color: vars.color.bodyText,
margin: "0",
});
globalStyle("button", {
padding: "0",
backgroundColor: "transparent",
border: "none",
fontFamily: "inherit",
fontSize: vars.size.bodyFontSize,
});
globalStyle("h1, h2, h3, h4, h5, h6, p", {
margin: 0,
});
globalStyle("#root, main", {
display: "flex",
});
globalStyle("#root", {
flexDirection: "column",
});
globalStyle("main", {
overflow: "hidden",
});
globalStyle(
"input::-webkit-outer-spin-button, input::-webkit-inner-spin-button",
{
WebkitAppearance: "none",
margin: "0",
}
);
globalStyle("label", {
fontSize: vars.size.bodyFontSize,
});
globalStyle("input[type=number]", {
MozAppearance: "textfield",
});
globalStyle("img", {
WebkitUserDrag: "none",
} as Record<string, string>);
export const container = style({
width: "100%",
height: "100%",
overflow: "hidden",
display: "flex",
flexDirection: "column",
});
export const content = style({
overflowY: "auto",
alignItems: "center",
display: "flex",
flexDirection: "column",
position: "relative",
height: "100%",
background: `linear-gradient(0deg, ${vars.color.darkBackground} 50%, ${vars.color.background} 100%)`,
});
export const titleBar = style({
display: "flex",
width: "100%",
height: "35px",
minHeight: "35px",
backgroundColor: vars.color.darkBackground,
alignItems: "center",
padding: `0 ${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
zIndex: "2",
borderBottom: `1px solid ${vars.color.borderColor}`,
} as ComplexStyleRule);

122
src/renderer/app.tsx Normal file
View file

@ -0,0 +1,122 @@
import { useCallback, useEffect, useRef } from "react";
import { Sidebar, BottomPanel, Header } from "@renderer/components";
import {
useAppDispatch,
useAppSelector,
useDownload,
useLibrary,
} from "@renderer/hooks";
import * as styles from "./app.css";
import { themeClass } from "./theme.css";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
setSearch,
clearSearch,
setUserPreferences,
setRepackersFriendlyNames,
} from "@renderer/features";
document.body.classList.add(themeClass);
export function App() {
const contentRef = useRef<HTMLDivElement>(null);
const { updateLibrary } = useLibrary();
const { clearDownload, addPacket } = useDownload();
const dispatch = useAppDispatch();
const navigate = useNavigate();
const location = useLocation();
const search = useAppSelector((state) => state.search.value);
useEffect(() => {
Promise.all([
window.electron.getUserPreferences(),
window.electron.getRepackersFriendlyNames(),
updateLibrary(),
]).then(([preferences, repackersFriendlyNames]) => {
dispatch(setUserPreferences(preferences));
dispatch(setRepackersFriendlyNames(repackersFriendlyNames));
});
}, [navigate, location.pathname, dispatch, updateLibrary]);
useEffect(() => {
const unsubscribe = window.electron.onDownloadProgress(
(downloadProgress) => {
if (downloadProgress.game.progress === 1) {
clearDownload();
updateLibrary();
return;
}
addPacket(downloadProgress);
}
);
return () => {
unsubscribe();
};
}, [clearDownload, addPacket, updateLibrary]);
const handleSearch = useCallback(
(query: string) => {
dispatch(setSearch(query));
if (query === "") {
navigate(-1);
return;
}
const searchParams = new URLSearchParams({
query,
});
navigate(`/search?${searchParams.toString()}`, {
replace: location.pathname.startsWith("/search"),
});
},
[dispatch, location.pathname, navigate]
);
const handleClear = useCallback(() => {
dispatch(clearSearch());
navigate(-1);
}, [dispatch, navigate]);
useEffect(() => {
if (contentRef.current) contentRef.current.scrollTop = 0;
}, [location.pathname, location.search]);
return (
<>
{window.electron.platform === "win32" && (
<div className={styles.titleBar}>
<h4>Hydra</h4>
</div>
)}
<main>
<Sidebar />
<article className={styles.container}>
<Header
onSearch={handleSearch}
search={search}
onClear={handleClear}
/>
<section ref={contentRef} className={styles.content}>
<Outlet />
</section>
</article>
</main>
<BottomPanel />
</>
);
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M3.537 0C2.165 0 1.66.506 1.66 1.879V18.44a4.262 4.262 0 0 0 .02.433c.031.3.037.59.316.92c.027.033.311.245.311.245c.153.075.258.13.43.2l8.335 3.491c.433.199.614.276.928.27h.002c.314.006.495-.071.928-.27l8.335-3.492c.172-.07.277-.124.43-.2c0 0 .284-.211.311-.243c.28-.33.285-.621.316-.92a4.261 4.261 0 0 0 .02-.434V1.879c0-1.373-.506-1.88-1.878-1.88zm13.366 3.11h.68c1.138 0 1.688.553 1.688 1.696v1.88h-1.374v-1.8c0-.369-.17-.54-.523-.54h-.235c-.367 0-.537.17-.537.539v5.81c0 .369.17.54.537.54h.262c.353 0 .523-.171.523-.54V8.619h1.373v2.143c0 1.144-.562 1.71-1.7 1.71h-.694c-1.138 0-1.7-.566-1.7-1.71V4.82c0-1.144.562-1.709 1.7-1.709zm-12.186.08h3.114v1.274H6.117v2.603h1.648v1.275H6.117v2.774h1.74v1.275h-3.14zm3.816 0h2.198c1.138 0 1.7.564 1.7 1.708v2.445c0 1.144-.562 1.71-1.7 1.71h-.799v3.338h-1.4zm4.53 0h1.4v9.201h-1.4zm-3.13 1.235v3.392h.575c.354 0 .523-.171.523-.54V4.965c0-.368-.17-.54-.523-.54zm-3.74 10.147a1.708 1.708 0 0 1 .591.108a1.745 1.745 0 0 1 .49.299l-.452.546a1.247 1.247 0 0 0-.308-.195a.91.91 0 0 0-.363-.068a.658.658 0 0 0-.28.06a.703.703 0 0 0-.224.163a.783.783 0 0 0-.151.243a.799.799 0 0 0-.056.299v.008a.852.852 0 0 0 .056.31a.7.7 0 0 0 .157.245a.736.736 0 0 0 .238.16a.774.774 0 0 0 .303.058a.79.79 0 0 0 .445-.116v-.339h-.548v-.565H7.37v1.255a2.019 2.019 0 0 1-.524.307a1.789 1.789 0 0 1-.683.123a1.642 1.642 0 0 1-.602-.107a1.46 1.46 0 0 1-.478-.3a1.371 1.371 0 0 1-.318-.455a1.438 1.438 0 0 1-.115-.58v-.008a1.426 1.426 0 0 1 .113-.57a1.449 1.449 0 0 1 .312-.46a1.418 1.418 0 0 1 .474-.309a1.58 1.58 0 0 1 .598-.111a1.708 1.708 0 0 1 .045 0zm11.963.008a2.006 2.006 0 0 1 .612.094a1.61 1.61 0 0 1 .507.277l-.386.546a1.562 1.562 0 0 0-.39-.205a1.178 1.178 0 0 0-.388-.07a.347.347 0 0 0-.208.052a.154.154 0 0 0-.07.127v.008a.158.158 0 0 0 .022.084a.198.198 0 0 0 .076.066a.831.831 0 0 0 .147.06c.062.02.14.04.236.061a3.389 3.389 0 0 1 .43.122a1.292 1.292 0 0 1 .328.17a.678.678 0 0 1 .207.24a.739.739 0 0 1 .071.337v.008a.865.865 0 0 1-.081.382a.82.82 0 0 1-.229.285a1.032 1.032 0 0 1-.353.18a1.606 1.606 0 0 1-.46.061a2.16 2.16 0 0 1-.71-.116a1.718 1.718 0 0 1-.593-.346l.43-.514c.277.223.578.335.9.335a.457.457 0 0 0 .236-.05a.157.157 0 0 0 .082-.142v-.008a.15.15 0 0 0-.02-.077a.204.204 0 0 0-.073-.066a.753.753 0 0 0-.143-.062a2.45 2.45 0 0 0-.233-.062a5.036 5.036 0 0 1-.413-.113a1.26 1.26 0 0 1-.331-.16a.72.72 0 0 1-.222-.243a.73.73 0 0 1-.082-.36v-.008a.863.863 0 0 1 .074-.359a.794.794 0 0 1 .214-.283a1.007 1.007 0 0 1 .34-.185a1.423 1.423 0 0 1 .448-.066a2.006 2.006 0 0 1 .025 0m-9.358.025h.742l1.183 2.81h-.825l-.203-.499H8.623l-.198.498h-.81zm2.197.02h.814l.663 1.08l.663-1.08h.814v2.79h-.766v-1.602l-.711 1.091h-.016l-.707-1.083v1.593h-.754zm3.469 0h2.235v.658h-1.473v.422h1.334v.61h-1.334v.442h1.493v.658h-2.255zm-5.3.897l-.315.793h.624zm-1.145 5.19h8.014l-4.09 1.348z"/></svg>

After

Width:  |  Height:  |  Size: 2.9 KiB

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,928 @@
{
"v": "4.8.0",
"meta": { "g": "LottieFiles AE 3.5.6", "a": "", "k": "", "d": "", "tc": "" },
"fr": 60,
"ip": 0,
"op": 120,
"w": 300,
"h": 300,
"nm": "Comp 1",
"ddd": 0,
"assets": [
{
"id": "comp_0",
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 5,
"nm": "3",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [930, 525, 0], "ix": 2 },
"a": { "a": 0, "k": [16.605, -23.904, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[14.987, -34.426],
[9.105, -30.309],
[9.987, -22.073],
[17.487, -16.779],
[24.105, -23.544],
[22.193, -30.603]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 2,
"ty": 5,
"nm": "2",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [-8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [960, 540, 0], "ix": 2 },
"a": { "a": 0, "k": [31.912, -13.397, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[31.31, -34.72],
[24.546, -22.514],
[16.605, -16.485],
[17.046, -11.338],
[21.163, -7.073],
[27.487, -0.309],
[33.663, 10.133],
[47.634, -1.926],
[51.31, -12.073]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
},
{
"ddd": 0,
"ind": 3,
"ty": 5,
"nm": "✨",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": {
"a": 1,
"k": [
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [0]
},
{
"i": { "x": [0.055], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [8]
},
{ "t": 60, "s": [0] }
],
"ix": 10
},
"p": { "a": 0, "k": [935, 560, 0], "ix": 2 },
"a": { "a": 0, "k": [14.973, -6.64, 0], "ix": 1 },
"s": { "a": 0, "k": [170, 170, 100], "ix": 6 }
},
"ao": 0,
"hasMask": true,
"masksProperties": [
{
"inv": false,
"mode": "a",
"pt": {
"a": 0,
"k": {
"i": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"o": [
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0],
[0, 0]
],
"v": [
[13.957, -17.514],
[2.928, -9.132],
[2.487, 1.603],
[14.105, 7.339],
[21.605, -0.161],
[22.193, -5.161],
[17.34, -10.014]
],
"c": true
},
"ix": 1
},
"o": { "a": 0, "k": 100, "ix": 3 },
"x": { "a": 0, "k": 0, "ix": 4 },
"nm": "Mask 1"
}
],
"ef": [
{
"ty": 21,
"nm": "Fill",
"np": 9,
"mn": "ADBE Fill",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 10,
"nm": "Fill Mask",
"mn": "ADBE Fill-0001",
"ix": 1,
"v": { "a": 0, "k": 0, "ix": 1 }
},
{
"ty": 7,
"nm": "All Masks",
"mn": "ADBE Fill-0007",
"ix": 2,
"v": { "a": 0, "k": 0, "ix": 2 }
},
{
"ty": 2,
"nm": "Color",
"mn": "ADBE Fill-0002",
"ix": 3,
"v": {
"a": 0,
"k": [0.992156863213, 0.880375564098, 0.128396704793, 1],
"ix": 3
}
},
{
"ty": 7,
"nm": "Invert",
"mn": "ADBE Fill-0006",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Horizontal Feather",
"mn": "ADBE Fill-0003",
"ix": 5,
"v": { "a": 0, "k": 0, "ix": 5 }
},
{
"ty": 0,
"nm": "Vertical Feather",
"mn": "ADBE Fill-0004",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Fill-0005",
"ix": 7,
"v": { "a": 0, "k": 1, "ix": 7 }
}
]
}
],
"t": {
"d": {
"k": [
{
"s": {
"s": 40,
"f": "SegoeUIEmoji",
"t": "✨",
"j": 0,
"tr": 0,
"lh": 48,
"ls": 0,
"fc": [1, 1, 1]
},
"t": 0
}
]
},
"p": {},
"m": { "g": 1, "a": { "a": 0, "k": [0, 0], "ix": 2 } },
"a": []
},
"ip": 0,
"op": 123,
"st": 0,
"bm": 0
}
]
}
],
"fonts": {
"list": [
{
"fName": "SegoeUIEmoji",
"fFamily": "Segoe UI Emoji",
"fStyle": "Regular",
"ascent": 74.0234375
}
]
},
"layers": [
{
"ddd": 0,
"ind": 1,
"ty": 0,
"nm": "botão",
"refId": "comp_0",
"sr": 1,
"ks": {
"o": { "a": 0, "k": 100, "ix": 11 },
"r": { "a": 0, "k": 0, "ix": 10 },
"p": { "a": 0, "k": [155, 154, 0], "ix": 2 },
"a": { "a": 0, "k": [960, 540, 0], "ix": 1 },
"s": { "a": 0, "k": [100, 100, 100], "ix": 6 }
},
"ao": 0,
"ef": [
{
"ty": 25,
"nm": "Drop Shadow",
"np": 8,
"mn": "ADBE Drop Shadow",
"ix": 1,
"en": 1,
"ef": [
{
"ty": 2,
"nm": "Shadow Color",
"mn": "ADBE Drop Shadow-0001",
"ix": 1,
"v": {
"a": 0,
"k": [1, 0.829733371735, 0.414901971817, 1],
"ix": 1
}
},
{
"ty": 0,
"nm": "Opacity",
"mn": "ADBE Drop Shadow-0002",
"ix": 2,
"v": {
"a": 1,
"k": [
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 0,
"s": [127.5]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 15,
"s": [204]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 30,
"s": [127.5]
},
{
"i": { "x": [0], "y": [1] },
"o": { "x": [0.333], "y": [0] },
"t": 45,
"s": [204]
},
{ "t": 70, "s": [76.5] }
],
"ix": 2
}
},
{
"ty": 0,
"nm": "Direction",
"mn": "ADBE Drop Shadow-0003",
"ix": 3,
"v": { "a": 0, "k": 135, "ix": 3 }
},
{
"ty": 0,
"nm": "Distance",
"mn": "ADBE Drop Shadow-0004",
"ix": 4,
"v": { "a": 0, "k": 0, "ix": 4 }
},
{
"ty": 0,
"nm": "Softness",
"mn": "ADBE Drop Shadow-0005",
"ix": 5,
"v": { "a": 0, "k": 40, "ix": 5 }
},
{
"ty": 7,
"nm": "Shadow Only",
"mn": "ADBE Drop Shadow-0006",
"ix": 6,
"v": { "a": 0, "k": 0, "ix": 6 }
}
]
}
],
"w": 1920,
"h": 1080,
"ip": 0,
"op": 120,
"st": 0,
"bm": 0
}
],
"markers": [],
"chars": [
{
"ch": "✨",
"size": 40,
"style": "Regular",
"w": 137.3,
"data": {
"shapes": [
{
"ty": "gr",
"it": [
{
"ind": 0,
"ty": "sh",
"ix": 1,
"ks": {
"a": 0,
"k": {
"i": [
[0.423, 1.042],
[0, 0],
[0.7, 0],
[0.293, -0.618],
[0, 0],
[1.041, -0.488],
[0, 0],
[0, -0.684],
[-0.652, -0.293],
[0, 0],
[-0.423, -1.041],
[0, 0],
[-0.716, 0],
[-0.293, 0.619],
[0, 0],
[-1.042, 0.488],
[0, 0],
[0, 0.684],
[0.618, 0.293],
[0, 0]
],
"o": [
[0, 0],
[-0.326, -0.618],
[-0.7, 0],
[0, 0],
[-0.456, 1.009],
[0, 0],
[-0.652, 0.293],
[0, 0.684],
[0, 0],
[1.074, 0.456],
[0, 0],
[0.293, 0.619],
[0.716, 0],
[0, 0],
[0.455, -1.009],
[0, 0],
[0.618, -0.293],
[0, -0.684],
[0, 0],
[-1.074, -0.455]
],
"v": [
[47.119, -68.994],
[43.799, -76.562],
[42.261, -77.49],
[40.771, -76.562],
[37.402, -68.994],
[35.156, -66.748],
[30.908, -64.893],
[29.932, -63.428],
[30.908, -61.963],
[35.156, -60.107],
[37.402, -57.861],
[40.771, -50.244],
[42.285, -49.316],
[43.799, -50.244],
[47.119, -57.861],
[49.365, -60.107],
[53.662, -61.963],
[54.59, -63.428],
[53.662, -64.893],
[49.365, -66.748]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ind": 1,
"ty": "sh",
"ix": 2,
"ks": {
"a": 0,
"k": {
"i": [
[1.334, 3.223],
[0, 0],
[1.204, 0.423],
[1.204, -0.423],
[0.618, -1.237],
[0, 0],
[3.125, -1.432],
[0, 0],
[0.423, -1.221],
[-0.423, -1.221],
[-1.27, -0.618],
[0, 0],
[-1.335, -3.223],
[0, 0],
[-1.205, -0.407],
[-1.205, 0.407],
[-0.619, 1.27],
[0, 0],
[-3.125, 1.433],
[0, 0],
[-0.423, 1.221],
[0.423, 1.221],
[1.27, 0.619],
[0, 0]
],
"o": [
[0, 0],
[-0.619, -1.237],
[-1.205, -0.423],
[-1.205, 0.423],
[0, 0],
[-1.367, 3.223],
[0, 0],
[-1.27, 0.619],
[-0.423, 1.221],
[0.423, 1.221],
[0, 0],
[3.157, 1.433],
[0, 0],
[0.618, 1.27],
[1.204, 0.407],
[1.204, -0.407],
[0, 0],
[1.367, -3.223],
[0, 0],
[1.27, -0.618],
[0.423, -1.221],
[-0.423, -1.221],
[0, 0],
[-3.158, -1.432]
],
"v": [
[95.605, -50.83],
[85.498, -74.658],
[82.764, -77.148],
[79.15, -77.148],
[76.416, -74.658],
[66.357, -50.83],
[59.619, -43.848],
[46.875, -38.086],
[44.336, -35.327],
[44.336, -31.665],
[46.875, -28.906],
[59.619, -23.145],
[66.357, -16.162],
[76.416, 7.666],
[79.15, 10.181],
[82.764, 10.181],
[85.498, 7.666],
[95.605, -16.162],
[102.344, -23.145],
[115.088, -28.906],
[117.627, -31.665],
[117.627, -35.327],
[115.088, -38.086],
[102.344, -43.848]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
},
{
"ind": 2,
"ty": "sh",
"ix": 3,
"ks": {
"a": 0,
"k": {
"i": [
[-1.367, -0.651],
[0, 0],
[0, -0.928],
[0.813, -0.423],
[0, 0],
[0.586, -1.399],
[0, 0],
[0.895, 0],
[0.391, 0.846],
[0, 0],
[1.334, 0.652],
[0, 0],
[0, 0.928],
[-0.814, 0.423],
[0, 0],
[-0.586, 1.4],
[0, 0],
[-0.896, 0],
[-0.391, -0.846],
[0, 0]
],
"o": [
[0, 0],
[0.813, 0.423],
[0, 0.928],
[0, 0],
[-1.335, 0.652],
[0, 0],
[-0.391, 0.846],
[-0.896, 0],
[0, 0],
[-0.586, -1.399],
[0, 0],
[-0.814, -0.423],
[0, -0.928],
[0, 0],
[1.334, -0.651],
[0, 0],
[0.391, -0.846],
[0.895, 0],
[0, 0],
[0.553, 1.4]
],
"v": [
[44.385, -16.943],
[49.854, -14.404],
[51.074, -12.378],
[49.854, -10.352],
[44.385, -7.812],
[41.504, -4.736],
[37.158, 5.713],
[35.229, 6.982],
[33.301, 5.713],
[28.955, -4.736],
[26.074, -7.812],
[20.605, -10.352],
[19.385, -12.378],
[20.605, -14.404],
[26.074, -16.943],
[28.955, -20.02],
[33.301, -30.469],
[35.229, -31.738],
[37.158, -30.469],
[41.504, -20.02]
],
"c": true
},
"ix": 2
},
"nm": "✨",
"mn": "ADBE Vector Shape - Group",
"hd": false
}
],
"nm": "✨",
"np": 6,
"cix": 2,
"bm": 0,
"ix": 1,
"mn": "ADBE Vector Group",
"hd": false
}
]
},
"fFamily": "Segoe UI Emoji"
}
]
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a10 10 0 0 1 10 10a10 10 0 0 1-10 10c-4.6 0-8.45-3.08-9.64-7.27l3.83 1.58a2.843 2.843 0 0 0 2.78 2.27c1.56 0 2.83-1.27 2.83-2.83v-.13l3.4-2.43h.08c2.08 0 3.77-1.69 3.77-3.77s-1.69-3.77-3.77-3.77s-3.78 1.69-3.78 3.77v.05l-2.37 3.46l-.16-.01c-.59 0-1.14.18-1.59.49L2 11.2C2.43 6.05 6.73 2 12 2M8.28 17.17c.8.33 1.72-.04 2.05-.84c.33-.8-.05-1.71-.83-2.04l-1.28-.53c.49-.18 1.04-.19 1.56.03c.53.21.94.62 1.15 1.15c.22.52.22 1.1 0 1.62c-.43 1.08-1.7 1.6-2.78 1.15c-.5-.21-.88-.59-1.09-1.04zm9.52-7.75c0 1.39-1.13 2.52-2.52 2.52a2.52 2.52 0 0 1-2.51-2.52a2.5 2.5 0 0 1 2.51-2.51a2.52 2.52 0 0 1 2.52 2.51m-4.4 0c0 1.04.84 1.89 1.89 1.89c1.04 0 1.88-.85 1.88-1.89s-.84-1.89-1.88-1.89c-1.05 0-1.89.85-1.89 1.89"/></svg>

After

Width:  |  Height:  |  Size: 828 B

View file

@ -0,0 +1,27 @@
import { forwardRef, useEffect, useState } from "react";
export interface AsyncImageProps
extends React.DetailedHTMLProps<
React.ImgHTMLAttributes<HTMLImageElement>,
HTMLImageElement
> {
onSettled?: (url: string) => void;
}
export const AsyncImage = forwardRef<HTMLImageElement, AsyncImageProps>(
({ onSettled, ...props }, ref) => {
const [source, setSource] = useState<string | null>(null);
useEffect(() => {
if (props.src && props.src.startsWith("http")) {
window.electron.getOrCacheImage(props.src).then((url) => {
setSource(url);
if (onSettled) onSettled(url);
});
}
}, [props.src, onSettled]);
return <img ref={ref} {...props} src={source ?? props.src} />;
}
);

View file

@ -0,0 +1,22 @@
import { SPACING_UNIT, vars } from "../../theme.css";
import { style } from "@vanilla-extract/css";
export const bottomPanel = style({
width: "100%",
borderTop: `solid 1px ${vars.color.borderColor}`,
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "center",
transition: "all ease 0.2s",
justifyContent: "space-between",
fontSize: vars.size.bodyFontSize,
zIndex: "1",
});
export const downloadsButton = style({
cursor: "pointer",
color: vars.color.bodyText,
":hover": {
textDecoration: "underline",
},
});

View file

@ -0,0 +1,68 @@
import { useTranslation } from "react-i18next";
import { useDownload } from "@renderer/hooks";
import * as styles from "./bottom-panel.css";
import { vars } from "@renderer/theme.css";
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { VERSION_CODENAME } from "@renderer/constants";
export function BottomPanel() {
const { t } = useTranslation("bottom_panel");
const navigate = useNavigate();
const { game, progress, downloadSpeed, eta, isDownloading } = useDownload();
const [version, setVersion] = useState("");
useEffect(() => {
window.electron.getVersion().then((result) => setVersion(result));
}, []);
const status = useMemo(() => {
if (isDownloading) {
if (game.status === "downloading_metadata")
return t("downloading_metadata", { title: game.title });
if (game.status === "checking_files")
return t("checking_files", {
title: game.title,
percentage: progress,
});
return t("downloading", {
title: game?.title,
percentage: progress,
eta,
speed: downloadSpeed,
});
}
return t("no_downloads_in_progress");
}, [t, game, progress, eta, isDownloading, downloadSpeed]);
return (
<footer
className={styles.bottomPanel}
style={{
background: isDownloading
? `linear-gradient(90deg, ${vars.color.background} ${progress}, ${vars.color.darkBackground} ${progress})`
: vars.color.darkBackground,
}}
>
<button
type="button"
className={styles.downloadsButton}
onClick={() => navigate("/downloads")}
>
<small>{status}</small>
</button>
<small>
v{version} "{VERSION_CODENAME}"
</small>
</footer>
);
}

View file

@ -0,0 +1,52 @@
import { style, styleVariants } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "../../theme.css";
const base = style({
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
backgroundColor: "#c0c1c7",
borderRadius: "8px",
border: "solid 1px transparent",
transition: "all ease 0.2s",
cursor: "pointer",
minHeight: "40px",
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: `${SPACING_UNIT}px`,
":active": {
opacity: vars.opacity.active,
},
":disabled": {
opacity: vars.opacity.disabled,
pointerEvents: "none",
},
});
export const button = styleVariants({
primary: [
base,
{
":hover": {
backgroundColor: "#DADBE1",
},
},
],
outline: [
base,
{
backgroundColor: "transparent",
border: "solid 1px #c0c1c7",
color: "#c0c1c7",
":hover": {
backgroundColor: "rgba(255, 255, 255, 0.1)",
},
},
],
dark: [
base,
{
backgroundColor: vars.color.darkBackground,
color: "#c0c1c7",
},
],
});

View file

@ -0,0 +1,27 @@
import cn from "classnames";
import * as styles from "./button.css";
export interface ButtonProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
theme?: keyof typeof styles.button;
}
export function Button({
children,
theme = "primary",
className,
...props
}: ButtonProps) {
return (
<button
{...props}
type="button"
className={cn(styles.button[theme], className)}
>
{children}
</button>
);
}

View file

@ -0,0 +1,40 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { style } from "@vanilla-extract/css";
export const checkboxField = style({
display: "flex",
flexDirection: "row",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
});
export const checkbox = style({
width: "20px",
height: "20px",
borderRadius: "4px",
backgroundColor: vars.color.darkBackground,
display: "flex",
justifyContent: "center",
alignItems: "center",
position: "relative",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.borderColor}`,
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
});
export const checkboxInput = style({
width: "100%",
height: "100%",
position: "absolute",
margin: "0",
padding: "0",
opacity: "0",
cursor: "pointer",
});
export const checkboxLabel = style({
cursor: "pointer",
});

View file

@ -0,0 +1,32 @@
import { useId } from "react";
import * as styles from "./checkbox-field.css";
import { CheckIcon } from "@primer/octicons-react";
export interface CheckboxFieldProps
extends React.DetailedHTMLProps<
React.InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
> {
label: string;
}
export function CheckboxField({ label, ...props }: CheckboxFieldProps) {
const id = useId();
return (
<div className={styles.checkboxField}>
<div className={styles.checkbox}>
<input
id={id}
type="checkbox"
className={styles.checkboxInput}
{...props}
/>
{props.checked && <CheckIcon />}
</div>
<label htmlFor={id} className={styles.checkboxLabel}>
{label}
</label>
</div>
);
}

View file

@ -0,0 +1,127 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const card = recipe({
base: {
width: "100%",
height: "180px",
boxShadow: "0px 0px 15px 0px #000000",
overflow: "hidden",
borderRadius: "4px",
transition: "all ease 0.2s",
border: `solid 1px ${vars.color.borderColor}`,
cursor: "pointer",
zIndex: "1",
":active": {
opacity: vars.opacity.active,
},
},
variants: {
disabled: {
true: {
pointerEvents: "none",
boxShadow: "none",
opacity: vars.opacity.disabled,
filter: "grayscale(50%)",
},
},
},
});
export const backdrop = style({
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.7) 50%, transparent 100%)",
width: "100%",
height: "100%",
display: "flex",
justifyContent: "flex-end",
flexDirection: "column",
position: "relative",
});
export const cover = style({
width: "100%",
height: "100%",
objectFit: "cover",
objectPosition: "center",
position: "absolute",
zIndex: "-1",
transition: "all ease 0.2s",
selectors: {
[`${card({})}:hover &`]: {
transform: "scale(1.05)",
},
},
});
export const content = style({
color: "#DADBE1",
padding: `${SPACING_UNIT}px ${SPACING_UNIT * 2}px`,
display: "flex",
alignItems: "flex-start",
gap: `${SPACING_UNIT}px`,
flexDirection: "column",
transition: "all ease 0.2s",
transform: "translateY(24px)",
selectors: {
[`${card({})}:hover &`]: {
transform: "translateY(0px)",
},
},
});
export const title = style({
fontSize: "16px",
fontWeight: "bold",
textAlign: "left",
});
export const downloadOptions = style({
display: "flex",
margin: "0",
padding: "0",
gap: `${SPACING_UNIT}px`,
flexWrap: "wrap",
});
export const downloadOption = style({
color: "#c0c1c7",
fontSize: "10px",
padding: `${SPACING_UNIT / 2}px ${SPACING_UNIT}px`,
border: "solid 1px #c0c1c7",
borderRadius: "4px",
display: "flex",
alignItems: "center",
});
export const specifics = style({
display: "flex",
gap: `${SPACING_UNIT * 2}px`,
justifyContent: "center",
});
export const specificsItem = style({
gap: `${SPACING_UNIT}px`,
display: "flex",
color: "#c0c1c7",
fontSize: "12px",
alignItems: "flex-end",
});
export const titleContainer = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
color: "#c0c1c7",
});
export const shopIcon = style({
width: "20px",
height: "20px",
minWidth: "20px",
});
export const noDownloadsLabel = style({
color: vars.color.bodyText,
fontWeight: "bold",
});

View file

@ -0,0 +1,87 @@
import { DownloadIcon, FileDirectoryIcon } from "@primer/octicons-react";
import type { CatalogueEntry } from "@types";
import SteamLogo from "@renderer/assets/steam-logo.svg";
import EpicGamesLogo from "@renderer/assets/epic-games-logo.svg";
import { AsyncImage } from "../async-image/async-image";
import * as styles from "./game-card.css";
import { useAppSelector } from "@renderer/hooks";
import { useTranslation } from "react-i18next";
export interface GameCardProps
extends React.DetailedHTMLProps<
React.ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> {
game: CatalogueEntry;
disabled?: boolean;
}
const shopIcon = {
epic: <EpicGamesLogo className={styles.shopIcon} />,
steam: <SteamLogo className={styles.shopIcon} />,
};
export function GameCard({ game, disabled, ...props }: GameCardProps) {
const { t } = useTranslation("game_card");
const repackersFriendlyNames = useAppSelector(
(state) => state.repackersFriendlyNames.value
);
const uniqueRepackers = Array.from(
new Set(game.repacks.map(({ repacker }) => repacker))
);
return (
<button
{...props}
type="button"
className={styles.card({ disabled })}
disabled={disabled}
>
<div className={styles.backdrop}>
<AsyncImage
src={game.cover}
alt={game.title}
className={styles.cover}
/>
<div className={styles.content}>
<div className={styles.titleContainer}>
{shopIcon[game.shop]}
<p className={styles.title}>{game.title}</p>
</div>
{uniqueRepackers.length > 0 ? (
<ul className={styles.downloadOptions}>
{uniqueRepackers.map((repacker) => (
<li key={repacker} className={styles.downloadOption}>
<span>{repackersFriendlyNames[repacker]}</span>
</li>
))}
</ul>
) : (
<p className={styles.noDownloadsLabel}>{t("no_downloads")}</p>
)}
<div className={styles.specifics}>
<div className={styles.specificsItem}>
<DownloadIcon />
<span>{game.repacks.length}</span>
</div>
{game.repacks.length > 0 && (
<div className={styles.specificsItem}>
<FileDirectoryIcon />
<span>{game.repacks.at(0)?.fileSize}</span>
</div>
)}
</div>
</div>
</div>
</button>
);
}

View file

@ -0,0 +1,148 @@
import type { ComplexStyleRule } from "@vanilla-extract/css";
import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export const slideIn = keyframes({
"0%": { transform: "translateX(20px)", opacity: "0" },
"100%": {
transform: "translateX(0)",
opacity: "1",
},
});
export const slideOut = keyframes({
"0%": { transform: "translateX(0px)", opacity: "1" },
"100%": {
transform: "translateX(20px)",
opacity: "0",
},
});
export const header = recipe({
base: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
WebkitAppRegion: "drag",
width: "100%",
padding: `${SPACING_UNIT * 2}px ${SPACING_UNIT * 3}px`,
color: "#c0c1c7",
borderBottom: `solid 1px ${vars.color.borderColor}`,
backgroundColor: vars.color.darkBackground,
} as ComplexStyleRule,
variants: {
draggingDisabled: {
true: {
WebkitAppRegion: "no-drag",
} as ComplexStyleRule,
},
isWindows: {
true: {
WebkitAppRegion: "no-drag",
} as ComplexStyleRule,
},
},
});
export const search = recipe({
base: {
backgroundColor: vars.color.background,
display: "inline-flex",
transition: "all ease 0.2s",
width: "200px",
alignItems: "center",
borderRadius: "8px",
border: `solid 1px ${vars.color.borderColor}`,
height: "40px",
WebkitAppRegion: "no-drag",
} as ComplexStyleRule,
variants: {
focused: {
true: {
width: "250px",
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
},
});
export const searchInput = style({
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
fontSize: vars.size.bodyFontSize,
textOverflow: "ellipsis",
":focus": {
cursor: "text",
},
});
export const actionButton = style({
color: "inherit",
cursor: "pointer",
transition: "all ease 0.2s",
padding: `${SPACING_UNIT}px`,
":hover": {
color: "#DADBE1",
},
});
export const section = style({
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT * 2}px`,
height: "100%",
});
export const backButton = recipe({
base: {
color: vars.color.bodyText,
cursor: "pointer",
WebkitAppRegion: "no-drag",
position: "absolute",
transition: "transform ease 0.2s",
animationDuration: "0.2s",
width: "16px",
height: "16px",
display: "flex",
alignItems: "center",
} as ComplexStyleRule,
variants: {
enabled: {
true: {
animationName: slideIn,
},
false: {
opacity: "0",
pointerEvents: "none",
animationName: slideOut,
},
},
},
});
export const title = recipe({
base: {
transition: "all ease 0.2s",
},
variants: {
hasBackButton: {
true: {
transform: "translateX(28px)",
},
},
},
});

View file

@ -0,0 +1,125 @@
import { useTranslation } from "react-i18next";
import { useEffect, useMemo, useRef, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { ArrowLeftIcon, SearchIcon, XIcon } from "@primer/octicons-react";
import { useAppDispatch, useAppSelector } from "@renderer/hooks";
import * as styles from "./header.css";
import { clearSearch } from "@renderer/features";
export interface HeaderProps {
onSearch: (query: string) => void;
onClear: () => void;
search?: string;
}
const pathTitle: Record<string, string> = {
"/": "catalogue",
"/downloads": "downloads",
"/settings": "settings",
};
export function Header({ onSearch, onClear, search }: HeaderProps) {
const inputRef = useRef<HTMLInputElement>(null);
const navigate = useNavigate();
const location = useLocation();
const { headerTitle, draggingDisabled } = useAppSelector(
(state) => state.window
);
const dispatch = useAppDispatch();
const [isFocused, setIsFocused] = useState(false);
const { t } = useTranslation("header");
const title = useMemo(() => {
if (location.pathname.startsWith("/game")) return headerTitle;
if (location.pathname.startsWith("/search")) return t("search_results");
return t(pathTitle[location.pathname]);
}, [location.pathname, headerTitle, t]);
useEffect(() => {
if (search && !location.pathname.startsWith("/search")) {
dispatch(clearSearch());
}
}, [location.pathname, search, dispatch]);
const focusInput = () => {
setIsFocused(true);
inputRef.current?.focus();
};
const handleBlur = () => {
setIsFocused(false);
};
const handleBackButtonClick = () => {
navigate(-1);
};
return (
<header
className={styles.header({
draggingDisabled,
isWindows: window.electron.platform === "win32",
})}
>
<div className={styles.section}>
<button
type="button"
className={styles.backButton({ enabled: location.key !== "default" })}
onClick={handleBackButtonClick}
disabled={location.key === "default"}
>
<ArrowLeftIcon />
</button>
<h3
className={styles.title({
hasBackButton: location.key !== "default",
})}
>
{title}
</h3>
</div>
<section className={styles.section}>
<div className={styles.search({ focused: isFocused })}>
<button
type="button"
className={styles.actionButton}
onClick={focusInput}
>
<SearchIcon />
</button>
<input
ref={inputRef}
type="text"
name="search"
placeholder={t("search")}
value={search}
className={styles.searchInput}
onChange={(event) => onSearch(event.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={handleBlur}
/>
{search && (
<button
type="button"
onClick={onClear}
className={styles.actionButton}
>
<XIcon />
</button>
)}
</div>
</section>
</header>
);
}

View file

@ -0,0 +1,64 @@
import { style } from "@vanilla-extract/css";
import { SPACING_UNIT, vars } from "@renderer/theme.css";
export const hero = style({
width: "100%",
height: "280px",
minHeight: "280px",
maxHeight: "280px",
borderRadius: "8px",
color: "#DADBE1",
overflow: "hidden",
boxShadow: "0px 0px 15px 0px #000000",
cursor: "pointer",
border: `solid 1px ${vars.color.borderColor}`,
zIndex: "1",
"@media": {
"(min-width: 1250px)": {
backgroundPosition: "center",
},
},
});
export const heroMedia = style({
objectFit: "cover",
objectPosition: "center",
position: "absolute",
zIndex: "-1",
width: "100%",
height: "100%",
transition: "all ease 0.2s",
selectors: {
[`${hero}:hover &`]: {
transform: "scale(1.05)",
},
},
});
export const backdrop = style({
width: "100%",
height: "100%",
background: "linear-gradient(0deg, rgba(0, 0, 0, 0.6) 25%, transparent 100%)",
position: "relative",
display: "flex",
overflow: "hidden",
});
export const description = style({
maxWidth: "700px",
fontSize: vars.size.bodyFontSize,
color: "#c0c1c7",
textAlign: "left",
fontFamily: "'Fira Sans', sans-serif",
lineHeight: "20px",
});
export const content = style({
width: "100%",
height: "100%",
padding: `${SPACING_UNIT * 4}px ${SPACING_UNIT * 3}px`,
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
justifyContent: "flex-end",
});

View file

@ -0,0 +1,59 @@
import { useNavigate } from "react-router-dom";
import { AsyncImage } from "@renderer/components";
import * as styles from "./hero.css";
import { useEffect, useState } from "react";
import { ShopDetails } from "@types";
import { getSteamLanguage, steamUrlBuilder } from "@renderer/helpers";
import { useTranslation } from "react-i18next";
const FEATURED_GAME_ID = "377160";
export function Hero() {
const [featuredGameDetails, setFeaturedGameDetails] =
useState<ShopDetails | null>(null);
const { i18n } = useTranslation();
const navigate = useNavigate();
useEffect(() => {
window.electron
.getGameShopDetails(
FEATURED_GAME_ID,
"steam",
getSteamLanguage(i18n.language)
)
.then((result) => {
setFeaturedGameDetails(result);
});
}, [i18n.language]);
return (
<button
type="button"
onClick={() => navigate(`/game/steam/${FEATURED_GAME_ID}`)}
className={styles.hero}
>
<div className={styles.backdrop}>
<AsyncImage
src="https://cdn2.steamgriddb.com/hero/e7a7ba56b1be30e178cd52820e063396.png"
alt={featuredGameDetails?.name}
className={styles.heroMedia}
/>
<div className={styles.content}>
<AsyncImage
src={steamUrlBuilder.logo(FEATURED_GAME_ID)}
width="250px"
alt={featuredGameDetails?.name}
style={{ marginBottom: 16 }}
/>
<p className={styles.description}>
{featuredGameDetails?.short_description}
</p>
</div>
</div>
</button>
);
}

View file

@ -0,0 +1,10 @@
export * from "./bottom-panel/bottom-panel";
export * from "./button/button";
export * from "./game-card/game-card";
export * from "./header/header";
export * from "./hero/hero";
export * from "./modal/modal";
export * from "./sidebar/sidebar";
export * from "./async-image/async-image";
export * from "./text-field/text-field";
export * from "./checkbox-field/checkbox-field";

View file

@ -0,0 +1,108 @@
import { keyframes, style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const backdropFadeIn = keyframes({
"0%": { backdropFilter: "blur(0px)", backgroundColor: "rgba(0, 0, 0, 0.5)" },
"100%": {
backdropFilter: "blur(2px)",
backgroundColor: "rgba(0, 0, 0, 0.7)",
},
});
export const backdropFadeOut = keyframes({
"0%": { backdropFilter: "blur(2px)", backgroundColor: "rgba(0, 0, 0, 0.7)" },
"100%": {
backdropFilter: "blur(0px)",
backgroundColor: "rgba(0, 0, 0, 0)",
},
});
export const modalSlideIn = keyframes({
"0%": { opacity: 0 },
"100%": {
opacity: 1,
},
});
export const modalSlideOut = keyframes({
"0%": { opacity: 1 },
"100%": {
opacity: 0,
},
});
export const backdrop = recipe({
base: {
animationName: backdropFadeIn,
animationDuration: "0.4s",
backgroundColor: "rgba(0, 0, 0, 0.7)",
position: "absolute",
width: "100%",
height: "100%",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 1,
top: 0,
padding: `${SPACING_UNIT * 3}px`,
backdropFilter: "blur(2px)",
transition: "all ease 0.2s",
},
variants: {
closing: {
true: {
animationName: backdropFadeOut,
backdropFilter: "blur(0px)",
backgroundColor: "rgba(0, 0, 0, 0)",
},
},
},
});
export const modal = recipe({
base: {
animationName: modalSlideIn,
animationDuration: "0.3s",
backgroundColor: vars.color.background,
borderRadius: "5px",
maxWidth: "600px",
color: vars.color.bodyText,
maxHeight: "100%",
border: `solid 1px ${vars.color.borderColor}`,
overflow: "hidden",
display: "flex",
flexDirection: "column",
},
variants: {
closing: {
true: {
animationName: modalSlideOut,
opacity: 0,
},
},
},
});
export const modalContent = style({
height: "100%",
overflow: "auto",
padding: `${SPACING_UNIT * 3}px ${SPACING_UNIT * 2}px`,
});
export const modalHeader = style({
display: "flex",
gap: `${SPACING_UNIT}px`,
padding: `${SPACING_UNIT * 2}px`,
borderBottom: `solid 1px ${vars.color.borderColor}`,
justifyContent: "space-between",
alignItems: "flex-start",
});
export const closeModalButton = style({
cursor: "pointer",
});
export const closeModalButtonIcon = style({
color: vars.color.bodyText,
});

View file

@ -0,0 +1,69 @@
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
import { XIcon } from "@primer/octicons-react";
import * as styles from "./modal.css";
import { useAppDispatch } from "@renderer/hooks";
import { toggleDragging } from "@renderer/features";
export interface ModalProps {
visible: boolean;
title: string;
description: string;
onClose: () => void;
children: React.ReactNode;
}
export function Modal({
visible,
title,
description,
onClose,
children,
}: ModalProps) {
const [isClosing, setIsClosing] = useState(false);
const dispatch = useAppDispatch();
const handleCloseClick = () => {
setIsClosing(true);
const zero = performance.now();
requestAnimationFrame(function animateClosing(time) {
if (time - zero <= 400) {
requestAnimationFrame(animateClosing);
} else {
onClose();
setIsClosing(false);
}
});
};
useEffect(() => {
dispatch(toggleDragging(visible));
}, [dispatch, visible]);
if (!visible) return null;
return createPortal(
<div className={styles.backdrop({ closing: isClosing })}>
<div className={styles.modal({ closing: isClosing })}>
<div className={styles.modalHeader}>
<div style={{ display: "flex", gap: 4, flexDirection: "column" }}>
<h3>{title}</h3>
<p style={{ fontSize: 14 }}>{description}</p>
</div>
<button
type="button"
onClick={handleCloseClick}
className={styles.closeModalButton}
>
<XIcon className={styles.closeModalButtonIcon} size={24} />
</button>
</div>
<div className={styles.modalContent}>{children}</div>
</div>
</div>,
document.body
);
}

View file

@ -0,0 +1,14 @@
import { style } from "@vanilla-extract/css";
export const downloadIconWrapper = style({
width: "16px",
height: "12px",
position: "relative",
});
export const downloadIcon = style({
width: "24px",
position: "absolute",
left: "-4px",
top: "-9px",
});

View file

@ -0,0 +1,26 @@
import { useRef } from "react";
import Lottie from "lottie-react";
import downloadingAnimation from "@renderer/assets/lottie/downloading.json";
import * as styles from "./download-icon.css";
export interface DownloadIconProps {
isDownloading: boolean;
}
export function DownloadIcon({ isDownloading }: DownloadIconProps) {
const lottieRef = useRef(null);
return (
<div className={styles.downloadIconWrapper}>
<Lottie
lottieRef={lottieRef}
animationData={downloadingAnimation}
loop={isDownloading}
autoplay={isDownloading}
className={styles.downloadIcon}
onDOMLoaded={() => lottieRef.current?.setSpeed(1.7)}
/>
</div>
);
}

View file

@ -0,0 +1,22 @@
import { GearIcon, ListUnorderedIcon } from "@primer/octicons-react";
import { DownloadIcon } from "./download-icon";
export const routes = [
{
path: "/",
nameKey: "catalogue",
render: () => <ListUnorderedIcon />,
},
{
path: "/downloads",
nameKey: "downloads",
render: (isDownloading: boolean) => (
<DownloadIcon isDownloading={isDownloading} />
),
},
{
path: "/settings",
nameKey: "settings",
render: () => <GearIcon />,
},
];

View file

@ -0,0 +1,136 @@
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
import { SPACING_UNIT, vars } from "../../theme.css";
export const sidebar = recipe({
base: {
backgroundColor: vars.color.darkBackground,
color: "#c0c1c7",
display: "flex",
transition: "opacity ease 0.2s",
borderRight: `solid 1px ${vars.color.borderColor}`,
position: "relative",
},
variants: {
resizing: {
true: {
opacity: vars.opacity.active,
pointerEvents: "none",
},
},
},
});
export const content = recipe({
base: {
display: "flex",
flexDirection: "column",
padding: `${SPACING_UNIT * 2}px`,
paddingBottom: "0",
width: "100%",
overflow: "auto",
},
variants: {
macos: {
true: {
paddingTop: `${SPACING_UNIT * 6}px`,
},
},
},
});
export const handle = style({
width: "5px",
height: "100%",
cursor: "col-resize",
position: "absolute",
right: "0",
});
export const menu = style({
listStyle: "none",
padding: "0",
margin: "0",
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
overflow: "hidden",
});
export const menuItem = recipe({
base: {
transition: "all ease 0.1s",
cursor: "pointer",
textWrap: "nowrap",
display: "flex",
opacity: "0.9",
color: "#DADBE1",
":hover": {
opacity: "1",
},
},
variants: {
active: {
true: {
opacity: "1",
fontWeight: "bold",
},
},
muted: {
true: {
opacity: vars.opacity.disabled,
":hover": {
opacity: "1",
},
},
},
},
});
export const menuItemButton = style({
color: "inherit",
display: "flex",
alignItems: "center",
gap: `${SPACING_UNIT}px`,
cursor: "pointer",
overflow: "hidden",
width: "100%",
selectors: {
[`${menuItem({ active: true }).split(" ")[1]} &`]: {
fontWeight: "bold",
},
},
});
export const menuItemButtonLabel = style({
textOverflow: "ellipsis",
overflow: "hidden",
});
export const gameIcon = style({
width: "20px",
height: "20px",
borderRadius: "4px",
backgroundSize: "cover",
});
export const sectionTitle = style({
textTransform: "uppercase",
fontWeight: "bold",
});
export const section = recipe({
base: {
padding: `${SPACING_UNIT * 2}px 0`,
gap: `${SPACING_UNIT * 2}px`,
display: "flex",
flexDirection: "column",
},
variants: {
hasBorder: {
true: {
borderBottom: `solid 1px ${vars.color.borderColor}`,
},
},
},
});

View file

@ -0,0 +1,217 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocation, useNavigate } from "react-router-dom";
import type { Game } from "@types";
import { useDownload, useLibrary } from "@renderer/hooks";
import { AsyncImage, TextField } from "@renderer/components";
import { SPACING_UNIT } from "@renderer/theme.css";
import * as styles from "./sidebar.css";
import { routes } from "./routes";
const SIDEBAR_MIN_WIDTH = 200;
const SIDEBAR_INITIAL_WIDTH = 250;
const SIDEBAR_MAX_WIDTH = 450;
const initialSidebarWidth = window.localStorage.getItem("sidebarWidth");
export function Sidebar() {
const { t } = useTranslation("sidebar");
const { library, updateLibrary } = useLibrary();
const navigate = useNavigate();
const [filteredLibrary, setFilteredLibrary] = useState<Game[]>([]);
const [isResizing, setIsResizing] = useState(false);
const [sidebarWidth, setSidebarWidth] = useState(
initialSidebarWidth ? Number(initialSidebarWidth) : SIDEBAR_INITIAL_WIDTH
);
const location = useLocation();
const { game: gameDownloading, progress } = useDownload();
useEffect(() => {
updateLibrary();
}, [gameDownloading?.id, updateLibrary]);
const isDownloading = library.some((game) =>
["downloading", "checking_files", "downloading_metadata"].includes(
game.status
)
);
const sidebarRef = useRef<HTMLElement>(null);
const cursorPos = useRef({ x: 0 });
const sidebarInitialWidth = useRef(0);
const handleMouseDown: React.MouseEventHandler<HTMLButtonElement> = (
event
) => {
setIsResizing(true);
cursorPos.current.x = event.screenX;
sidebarInitialWidth.current =
sidebarRef.current?.clientWidth || SIDEBAR_INITIAL_WIDTH;
};
const handleFilter: React.ChangeEventHandler<HTMLInputElement> = (event) => {
setFilteredLibrary(
library.filter((game) =>
game.title
.toLowerCase()
.includes(event.target.value.toLocaleLowerCase())
)
);
};
useEffect(() => {
setFilteredLibrary(library);
}, [library]);
useEffect(() => {
window.onmousemove = (event) => {
if (isResizing) {
const cursorXDelta = event.screenX - cursorPos.current.x;
const newWidth = Math.max(
SIDEBAR_MIN_WIDTH,
Math.min(
sidebarInitialWidth.current + cursorXDelta,
SIDEBAR_MAX_WIDTH
)
);
setSidebarWidth(newWidth);
window.localStorage.setItem("sidebarWidth", String(newWidth));
}
};
window.onmouseup = () => {
if (isResizing) setIsResizing(false);
};
return () => {
window.onmouseup = null;
window.onmousemove = null;
};
}, [isResizing]);
const getGameTitle = (game: Game) => {
if (game.status === "paused") return t("paused", { title: game.title });
if (gameDownloading?.id === game.id) {
const isVerifying = ["downloading_metadata", "checking_files"].includes(
gameDownloading?.status
);
if (isVerifying)
return t(gameDownloading.status, {
title: game.title,
percentage: progress,
});
return t("downloading", {
title: game.title,
percentage: progress,
});
}
return game.title;
};
const handleSidebarItemClick = (path: string) => {
if (path !== location.pathname) {
navigate(path);
}
};
return (
<aside
ref={sidebarRef}
className={styles.sidebar({ resizing: isResizing })}
style={{
width: sidebarWidth,
minWidth: sidebarWidth,
maxWidth: sidebarWidth,
}}
>
<div
className={styles.content({
macos: window.electron.platform === "darwin",
})}
>
{window.electron.platform === "darwin" && (
<h2 style={{ marginBottom: SPACING_UNIT }}>Hydra</h2>
)}
<section className={styles.section({ hasBorder: false })}>
<ul className={styles.menu}>
{routes.map(({ nameKey, path, render }) => (
<li
key={nameKey}
className={styles.menuItem({
active: location.pathname === path,
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() => handleSidebarItemClick(path)}
>
{render(isDownloading)}
<span>{t(nameKey)}</span>
</button>
</li>
))}
</ul>
</section>
<section className={styles.section({ hasBorder: false })}>
<small className={styles.sectionTitle}>{t("my_library")}</small>
<TextField
placeholder={t("filter")}
onChange={handleFilter}
theme="dark"
/>
<ul className={styles.menu}>
{filteredLibrary.map((game) => (
<li
key={game.id}
className={styles.menuItem({
active:
location.pathname === `/game/${game.shop}/${game.objectID}`,
muted: game.status === null || game.status === "cancelled",
})}
>
<button
type="button"
className={styles.menuItemButton}
onClick={() =>
handleSidebarItemClick(
`/game/${game.shop}/${game.objectID}`
)
}
>
<AsyncImage className={styles.gameIcon} src={game.iconUrl} />
<span className={styles.menuItemButtonLabel}>
{getGameTitle(game)}
</span>
</button>
</li>
))}
</ul>
</section>
</div>
<button
type="button"
className={styles.handle}
onMouseDown={handleMouseDown}
/>
</aside>
);
}

View file

@ -0,0 +1,59 @@
import { SPACING_UNIT, vars } from "@renderer/theme.css";
import { style } from "@vanilla-extract/css";
import { recipe } from "@vanilla-extract/recipes";
export const textField = recipe({
base: {
display: "inline-flex",
transition: "all ease 0.2s",
width: "100%",
alignItems: "center",
borderRadius: "8px",
border: `solid 1px ${vars.color.borderColor}`,
height: "40px",
minHeight: "40px",
},
variants: {
focused: {
true: {
borderColor: "#DADBE1",
},
false: {
":hover": {
borderColor: "rgba(255, 255, 255, 0.5)",
},
},
},
theme: {
primary: {
backgroundColor: vars.color.darkBackground,
},
dark: {
backgroundColor: vars.color.background,
},
},
},
});
export const textFieldInput = style({
backgroundColor: "transparent",
border: "none",
width: "100%",
height: "100%",
outline: "none",
color: "#DADBE1",
cursor: "default",
fontFamily: "inherit",
fontSize: vars.size.bodyFontSize,
textOverflow: "ellipsis",
padding: `${SPACING_UNIT}px`,
":focus": {
cursor: "text",
},
});
export const label = style({
marginBottom: `${SPACING_UNIT}px`,
display: "block",
color: vars.color.bodyText,
});

Some files were not shown because too many files have changed in this diff Show more